Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -229,14 +229,18 @@ const analysisActionLabel = (p: Pack | null, zh: boolean) => p?.continuation?.ca
function AnalysisContinuation({pack: p, analysis: a}: {pack: Pack; analysis: Controller}) {
const c = p.continuation, zh = a.zh;
if (!c) return p.continuation_unavailable ? <p className="analysis-warning">{zh ? "基础分析暂不可读;本版仍可独立分析完整范围。" : "The base analysis cannot be read; this version can still assess its complete scope independently."} <button type="button" onClick={() => void a.read().catch(() => undefined)}>{zh ? "重试读取" : "Retry reading"}</button></p> : null;
const compared = p.result && c.base_available ? c.compared_pair_count : null;
const reassessment = c.can_reuse ? (zh ? "复用原判读" : "Reuse judgments")
: `${zh ? (p.result ? "已复核全部" : "完整复核") : "Reassess all"} ${p.episode_count} ${zh ? "片段" : "episodes"}`;
return <section className="analysis-continuation" aria-label={zh ? "分析版本变化" : "Analysis version changes"}>
<div className="analysis-continuation-controls">
<button type="button" onClick={() => {a.select(c.base_pack_id); requestAnimationFrame(() => a.panel.current?.focus({preventScroll: true}));}}>{zh ? "查看上一版" : "Previous version"}</button>
<details className="analysis-method"><summary>{zh ? "版本与变化" : "Version and changes"}</summary>
<div className="analysis-continuation-summary"><strong>{c.can_reuse ? (zh ? "证据内容未变" : "Evidence content unchanged") : (zh ? "接续上一版" : "Continue from previous version")}</strong><span>{reassessment}</span></div>
<p>{p.result && c.base_available ? `${c.verdict_changes.length} ${zh ? "项判断变化" : "changed judgments"}` : (zh ? "查看变化" : "Inspect changes")}</p>
<p>{compared == null ? (zh ? "共同判断项数量未知。" : "The shared judgment count is unknown.")
: compared === 0 ? (zh ? "无共同判断项" : "No shared judgments")
: zh ? `共同判断 ${compared} 项 · 变化 ${c.verdict_changes.length} 项` : `${compared} shared judgments · ${c.verdict_changes.length} changed`}</p>
{compared != null ? <p>{zh ? "共同判断项按相同片段和指标计数;数量或标签变化不证明分析质量变化。" : "Shared judgments use the same episode and metric. Counts and label changes do not establish changes in analysis quality."}</p> : null}
<dl><dt>{zh ? "上一版" : "Previous"}</dt><dd>{c.base_title} · {c.base_scope.revision}<br/>{c.base_episode_count} · {c.base_scope.window}</dd>
<dt>{zh ? "本版" : "Current"}</dt><dd>{c.current_scope.revision}<br/>{p.episode_count} · {c.current_scope.window}</dd>
<dt>{zh ? "来源变动" : "Source changes"}</dt><dd>{zh ? "新增 / 更正 / 删除" : "Added / revised / removed"}: {c.sources.added.length} / {c.sources.revised.length} / {c.sources.removed.length}</dd>
Expand Down
8 changes: 8 additions & 0 deletions docs/reference/trajectory-assessment.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,14 @@ versions remain accessible through the same reader and browser history.
Reading, registering or refreshing a successor never starts analysis. A failed,
cancelled or concurrently running successor does not replace a valid base result.

`compared_pair_count` is the number of shared episode/metric pairs in two
qualified outputs. It is `null` when either output is unavailable; zero means
there are no shared judgments, not that conclusions stayed unchanged. Changed
judgments are shown with this denominator, separately from added/removed eligible
pairs. Older projections without the count remain unknown in the reader.
These are structural version facts, not matched scientific comparisons or
evidence of analysis quality.

The typed comparison reuses a qualified output only when literal excerpts,
source identities/paths/line positions, episode questions, eligibility,
objective, selection/window and analyst configuration are equivalent. Capture
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 19 additions & 0 deletions examples/personal-workspace-browser/research-history.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ export const researchHistoryScenario = {
let holdCitation = false, failedCitation = false, citationStarted, releaseCitation;
let unavailableAnalysis = false, failedAnalysisPack = "a", episodeMetadataLimit = null, comparisonFixture = false;
let analysisWrites = 0, analysisReads = 0, localizedFixture = false, continuationFixture = false, equivalentContinuation = false;
let versionComparison = "shared";
let unavailableTask = false;
const taskReads = [];
try {
Expand Down Expand Up @@ -371,8 +372,11 @@ export const researchHistoryScenario = {
sources: {added: ['follow-up'], revised: [], removed: []}, episodes: {added: [], revised: ['variant-alpha'], removed: []},
can_reuse: false, configuration_changed: false, objective_changed: false, base_available: true, base_episode_count: 4,
reassessed_episode_ids: p.episodes.map(e => e.id), new_pair_count: 0, removed_pair_count: 0,
compared_pair_count: versionComparison === "disjoint" ? 0 : p.result.assessments.length,
verdict_changes: [{episode_id: 'variant-alpha', metric: 'benefit_test', before: 'yes', after: 'unknown'}],
};
if (!base && ["disjoint", "unchanged"].includes(versionComparison)) p.continuation.verdict_changes = [];
if (!base && versionComparison === "legacy") delete p.continuation.compared_pair_count;
}
for (const pack of [...readback.catalog.packs, readback.pack]) pack.config = {...pack.config, output_language: language};
if (readback.pack.result) {
Expand Down Expand Up @@ -1386,6 +1390,7 @@ print(json.dumps([
await version.locator('summary').focus();
await page.keyboard.press('Enter');
await version.getByText(/已复核全部 4 片段/).waitFor();
await version.getByText(/共同判断 \d+ 项 · 变化 1 项/).waitFor();
assert.match(await version.innerText(), /original-capture.*later-evidence/s);
assert.match(await version.innerText(), /variant-alpha.*已做收益检验.*证据不足/);
await version.locator('summary').click();
Expand All @@ -1400,13 +1405,27 @@ print(json.dumps([
await version.getByText('版本与变化', {exact: true}).waitFor();
await page.reload({waitUntil: 'networkidle'});
await version.getByText('版本与变化', {exact: true}).waitFor();
for (const [mode, label] of [["unchanged", /共同判断 \d+ 项 · 变化 0 项/], ["disjoint", "无共同判断项"], ["legacy", "共同判断项数量未知。"]]) {
versionComparison = mode;
await page.reload({waitUntil: 'networkidle'});
await version.getByText('版本与变化', {exact: true}).click();
await version.getByText(label, {exact: true}).waitFor();
if (mode !== "unchanged") assert.doesNotMatch(await version.innerText(), /共同判断 \d+ 项 · 变化 0 项|0 项判断变化/);
}
versionComparison = "shared";
await page.reload({waitUntil: 'networkidle'});
await page.setViewportSize({width: 390, height: 844});
await chineseAnalysis.evaluate(element => element.scrollIntoView({block: 'start'}));
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await page.screenshot({path: resolve(outputDir, 'research-analysis-continuation-mobile.png'), animations: 'disabled'});
await page.evaluate(key => localStorage.setItem(key, 'en'), 'loopx-pw-locale');
await page.reload({waitUntil: 'networkidle'});
await page.getByRole('button', {name: 'Previous version', exact: true}).waitFor();
versionComparison = "disjoint";
await page.reload({waitUntil: 'networkidle'});
await page.getByText('Version and changes', {exact: true}).click();
await page.getByText('No shared judgments', {exact: true}).waitFor();
versionComparison = "shared";
equivalentContinuation = true;
await page.reload({waitUntil: 'networkidle'});
await page.getByRole('button', {name: 'Reuse analysis', exact: true}).first().waitFor();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,17 @@ export function compareTrajectoryAssessments(params: Record<string, unknown>): T
const eligibleBefore = new Set(base.input.episodes.flatMap(e => e.eligible_metrics.map(metric => `${e.id}\0${metric}`)));
const newPairs = new Set(current.input.episodes.flatMap(e => e.eligible_metrics.map(metric => `${e.id}\0${metric}`)));
const result = params.output === undefined ? null : qualifyTrajectoryAssessment({...params, analysis_key: current.analysis_key});
const shared = (result?.assessments ?? []).flatMap(after => {
const before = oldPairs.get(pair(after));
return before ? [{before, after}] : [];
});
return {base_analysis_key: base.analysis_key, base_scope: base.input.scope,
current_scope: current.input.scope, sources, episodes, can_reuse,
configuration_changed: !same(base.config, current.config), objective_changed: base.input.objective !== current.input.objective,
reassessed_episode_ids: can_reuse ? [] : current.input.episodes.map(e => e.id),
new_pair_count: [...newPairs].filter(key => !eligibleBefore.has(key)).length,
removed_pair_count: [...eligibleBefore].filter(key => !newPairs.has(key)).length,
verdict_changes: (result?.assessments ?? []).flatMap(a => {
const old = oldPairs.get(pair(a));
return old && old.verdict !== a.verdict ? [{episode_id: a.episode_id, metric: a.metric, before: old.verdict, after: a.verdict}] : [];
})};
compared_pair_count: oldResult !== null && result !== null ? shared.length : null,
verdict_changes: shared.flatMap(({before, after}) => before.verdict !== after.verdict
? [{episode_id: after.episode_id, metric: after.metric, before: before.verdict, after: after.verdict}] : [])};
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ export interface TrajectoryContinuation {
episodes: {added: string[]; revised: string[]; removed: string[]};
can_reuse: boolean; configuration_changed: boolean; objective_changed: boolean;
reassessed_episode_ids: string[]; new_pair_count: number; removed_pair_count: number;
/** Shared episode/metric rows in two qualified outputs; null if either is unavailable. */
compared_pair_count: number | null;
verdict_changes: Array<{episode_id: string; metric: TrajectoryMetric; before: AssessmentVerdict; after: AssessmentVerdict}>;
}

Expand Down
28 changes: 28 additions & 0 deletions tests/capabilities/test_trajectory_assessment_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ def test_new_evidence_reassesses_old_judgments_and_complete_denominator(pack, tm
assert before["continuation"]["can_reuse"] is False
assert before["continuation"]["reassessed_episode_ids"] == ["episode", "follow-up"]
assert before["continuation"]["new_pair_count"] == 1
assert before["continuation"]["compared_pair_count"] is None
revised = {"assessments": [dict(episode_id=e, metric="benefit_test", verdict=v, reason="Later evidence corrects the earlier interpretation", citations=[dict(source_id="later", first_line=1, last_line=1)] if v != "unknown" else []) for e, v in [("episode", "no"), ("follow-up", "unknown")]], "limitations": ["Synthetic counterevidence"]}
revised["synthesis"] = {"summary": {"text": "The earlier interpretation was refuted by later evidence.", "episode_ids": ["episode"],
"citations": [dict(source_id="later", first_line=1, last_line=1)]}, "findings": [], "next_experiments": []}
Expand All @@ -259,9 +260,35 @@ def test_new_evidence_reassesses_old_judgments_and_complete_denominator(pack, tm
benefit = next(row for row in view["result"]["metrics"] if row["metric"] == "benefit_test")
assert (benefit["yes"], benefit["no"], benefit["unknown"], benefit["eligible"]) == (0, 1, 1, 2)
assert view["continuation"]["verdict_changes"] == [dict(episode_id="episode", metric="benefit_test", before="yes", after="no")]
assert view["continuation"]["compared_pair_count"] == 1
assert inspect_pack(**scope, pack_id=registered["pack_id"])["result"] == previous


def test_disjoint_research_units_have_zero_comparisons_without_overwriting_prior_result(pack, tmp_path):
scope, operation, registered, path, sources, provider = pack
execute_pack(**operation, codex=provider)
previous = inspect_pack(**scope)["result"]
old_files = {p: p.read_bytes() for p in scope["runtime_root"].glob("benchmark/trajectory-assessments/*/jobs/*/*") if p.is_file()}
raw = json.loads(next(p for p in old_files if p.name == "model-output.json").read_text())
data = json.loads(path.read_text())
data["scope"]["revision"] = "different-research-unit"
data["episodes"][0]["id"] = "new-question"
raw["assessments"][0]["episode_id"] = "new-question"
raw["synthesis"]["summary"]["episode_ids"] = ["new-question"]
path.write_text(json.dumps(data))
successor = register_pack(**scope, input_path=path, source_root=sources, title="Different research unit", base_pack_id=registered["pack_id"])
assert inspect_pack(**scope)["continuation"]["compared_pair_count"] is None
executable = tmp_path / "disjoint-output-fixture"
executable.write_text(f"#!{sys.executable}\nimport sys\nfrom pathlib import Path\nsys.stdin.read()\nPath(sys.argv[sys.argv.index('-o')+1]).write_text({json.dumps(raw)!r})\n")
executable.chmod(0o700)
execute_pack(**scope, pack_id=successor["pack_id"], analysis_key=successor["analysis_key"], codex=str(executable))
delta = inspect_pack(**scope)["continuation"]
assert (delta["compared_pair_count"], delta["new_pair_count"], delta["removed_pair_count"]) == (0, 1, 1)
assert delta["verdict_changes"] == []
assert inspect_pack(**scope, pack_id=registered["pack_id"])["result"] == previous
assert all(p.read_bytes() == content for p, content in old_files.items())


def test_continuation_does_not_cross_owner_run_or_revoked_source(pack, tmp_path):
scope, operation, registered, path, sources, provider = pack
execute_pack(**operation, codex=provider)
Expand All @@ -282,6 +309,7 @@ def test_continuation_does_not_cross_owner_run_or_revoked_source(pack, tmp_path)
view = inspect_pack(**scope)
assert view["availability"] == "available"
assert not view["continuation"]["base_available"] and not view["continuation"]["can_reuse"]
assert view["continuation"]["compared_pair_count"] is None
assert view["continuation"]["reassessed_episode_ids"] == ["episode"]
result = execute_pack(**scope, pack_id=successor["pack_id"], analysis_key=successor["analysis_key"], codex=provider)
assert result["status"] == "succeeded" and not result["reused"]
Expand Down
37 changes: 37 additions & 0 deletions tests/control_plane_ts/trajectory_assessment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,43 @@ test('later evidence can overturn an earlier judgment; full new denominators are
assert.throws(() => compareTrajectoryAssessments({...current, output: output(), previous: {...original, output: output()}}), /missing eligible/);
});

test('comparison denominator distinguishes unchanged judgments, disjoint units and unavailable results', () => {
const current = prepared(), previous = {...prepared(), output: output()};
const unchanged = compareTrajectoryAssessments({...current, previous, output: output()});
assert.equal(unchanged.compared_pair_count, 3);
assert.deepEqual(unchanged.verdict_changes, []);
for (const args of [
{...current, previous},
{...current, previous: prepared(), output: output()},
{...current, previous: prepared()},
]) assert.equal(compareTrajectoryAssessments(args).compared_pair_count, null, 'Missing qualified output is unknown, not zero comparisons');

const next = input(), renamed = output();
next.scope.revision = 'different-research-units';
for (const episode of next.episodes) episode.id = `new-${episode.id}`;
for (const assessment of renamed.assessments) assessment.episode_id = `new-${assessment.episode_id}`;
const disjoint = compareTrajectoryAssessments({input: next, config, previous, output: renamed});
assert.equal(disjoint.compared_pair_count, 0);
assert.equal(disjoint.new_pair_count, 3);
assert.equal(disjoint.removed_pair_count, 3);
assert.deepEqual(disjoint.verdict_changes, [], 'No shared rows is not evidence of unchanged conclusions');
});

test('comparison denominator counts shared episode and metric pairs, not total new judgments', () => {
const next = input(), revised = output();
next.episodes[0]!.eligible_metrics = ['information_gain'];
next.episodes.push({id: 'e3', question: 'Does the new intervention help?', source_ids: ['s'], eligible_metrics: ['information_gain']});
next.selection.population_episodes = 3;
revised.assessments.splice(1, 1);
revised.assessments[0]!.verdict = 'no';
revised.assessments.push({episode_id: 'e3', metric: 'information_gain', verdict: 'yes', reason: 'New evidence', citations: [{source_id: 's', first_line: 10, last_line: 10}]});
const delta = compareTrajectoryAssessments({input: next, config, previous: {...prepared(), output: output()}, output: revised});
assert.equal(delta.compared_pair_count, 2);
assert.equal(delta.new_pair_count, 1);
assert.equal(delta.removed_pair_count, 1);
assert.deepEqual(delta.verdict_changes, [{episode_id: 'e1', metric: 'information_gain', before: 'yes', after: 'no'}]);
});

test('corrections, removals, scope and model changes require full reassessment and identity cannot cross runs', () => {
const previous = {...prepared(), output: output()};
for (const edit of [(x: TrajectoryInput) => {x.sources[0]!.text = 'corrected negative evidence';},
Expand Down
Loading