diff --git a/docs/reference/trajectory-assessment.md b/docs/reference/trajectory-assessment.md index c1d2cd0dcc..01616f0555 100644 --- a/docs/reference/trajectory-assessment.md +++ b/docs/reference/trajectory-assessment.md @@ -10,9 +10,9 @@ It does not change solver prompts, benchmark scoring, checkpoints or research bu ## Researcher synthesis in the existing reader New registrations using the default configuration and CLI previews request -`research_synthesis: true` in their +`research_synthesis: true` and `research_synthesis_revision: 2` in their frozen model configuration. Explicit analysis uses the same observer call to -produce `research-episodes-synthesis-v1`: the six episode judgments plus a +produce `research-episodes-synthesis-v2`: the six episode judgments plus a source-bound `synthesis`. There is no extra per-finding model call. The ordinary Goal view follows **results → research report → research history**. Native measurements and unknown retention appear first; the saved report adds a concise @@ -48,13 +48,41 @@ in the summary. Useful negative evidence and observed late gains remain visible. Existing registered configurations without this field keep their original prompt, analysis key, schema and saved results. They are not automatically upgraded or rerun. For an explicitly requested synthesis of an older study, -register a new version with `config.research_synthesis: true`; keep the previous +register a new version with `config.research_synthesis: true` and +`config.research_synthesis_revision: 2`; keep the previous pack and its failures. The Python preparation adapter also accepts `research_synthesis=False` to reproduce the legacy method. To disable new synthesis, register with that field false or an explicit legacy configuration; no source history is deleted. A language change retains the existing independent language-specific cache and execution boundary. +Revision 2 refines the existing report fields, without new verdicts or a second +analysis call. It asks the observer to reconcile completed comparisons and +follow-up decisions before recommending work, preserve paired gains and +regressions, and distinguish configured limits from actual completed work. +Controls and predictions should explain whether the intervention engaged and +which missing observations would prevent interpretation. Equal seeds or time +limits do not alone prove matched work: distinguish fixed-work from wall-clock +comparisons and check the relevant coupling, allowing the intended state/path +divergence. A justified repeat with contemporary controls can still be useful. +These are model reasoning instructions; the validator does not certify that the +answer followed them or that a proposed experiment is scientifically sufficient. + +New default producer requests persist revision 2. Previously stored synthesis +configurations that omit the revision retain the exact v1 prompt, key and +language caches; reading or retrying them does not upgrade the method. Explicit +custom curator configurations also remain v1 when the revision is omitted. +To use revised guidance on an existing case, register a successor pack and +explicitly run that new analysis in the same workbench. Keep both reports and +their separate failures and usage. No new configuration screen, automatic +generation or experiment execution is introduced. + +新版指引要求先核对已完成比较和后续取舍,再提出下一步;把配置上限、实际完成工作及 +干预是否生效分开,并保留配对改善/回退和局部结果未转移到原生测评的边界。相同种子 +或墙钟期限不等于工作量匹配;应核对相关耦合,也允许机制本身造成后续路径分歧。 +新生产请求保存第2版;缺少版本字段的旧配置保持原提示、标识和语言缓存。接续分析须 +另行明确生成,原报告、失败与用量保留。引用资格和指引文字均不构成科学有效性证明。 + Curators must admit relevant prior feedback into each episode that needs it. A source existing elsewhere in the pack does not authorize a metric to cite it. When prior context or submitted-code excerpts are missing, revise the frozen diff --git a/loopx/capabilities/benchmark_toolkit/trajectory_assessment.py b/loopx/capabilities/benchmark_toolkit/trajectory_assessment.py index 387d6cb29b..7fe9f41a1b 100644 --- a/loopx/capabilities/benchmark_toolkit/trajectory_assessment.py +++ b/loopx/capabilities/benchmark_toolkit/trajectory_assessment.py @@ -49,7 +49,7 @@ def prepare( { "input": json.loads(input_path.read_text(encoding="utf-8")), "config": {"model": model, "effort": effort, "service_tier": service_tier, - **({"research_synthesis": True} if research_synthesis else {})}, + **({"research_synthesis": True, "research_synthesis_revision": 2} if research_synthesis else {})}, }, ) diff --git a/loopx/capabilities/benchmark_toolkit/trajectory_assessment_store.py b/loopx/capabilities/benchmark_toolkit/trajectory_assessment_store.py index fb78213704..8b18610fa3 100644 --- a/loopx/capabilities/benchmark_toolkit/trajectory_assessment_store.py +++ b/loopx/capabilities/benchmark_toolkit/trajectory_assessment_store.py @@ -16,7 +16,8 @@ from ...file_lock import exclusive_file_lock from .trajectory_assessment import _digest, _write, execute, read_job, request_cancel -DEFAULT_CONFIG = {"model": "gpt-6.1-sol", "effort": "max", "service_tier": "fast", "research_synthesis": True} +DEFAULT_CONFIG = {"model": "gpt-6.1-sol", "effort": "max", "service_tier": "fast", "research_synthesis": True, + "research_synthesis_revision": 2} class PackUnavailable(ValueError): diff --git a/loopx/control_plane/capabilities/trajectory_assessment.ts b/loopx/control_plane/capabilities/trajectory_assessment.ts index 162d895edc..d4d789d6e7 100644 --- a/loopx/control_plane/capabilities/trajectory_assessment.ts +++ b/loopx/control_plane/capabilities/trajectory_assessment.ts @@ -195,6 +195,15 @@ Answer what was learned about the original objective, the important turning poin The summary is a concise account of this declared sample, not the whole run. Return up to three findings, ordered by research value. For each, separate the directly observed result from your interpretation and concrete competing explanations. Preserve useful negative results and real late gains as positive signals without claiming their mechanism is proven. Empty findings are valid when evidence is insufficient; explain the gap in the summary. Return up to two next_experiments in priority order, only when justified by this evidence. State the question, why it matters now, the smallest intervention, controls including relevant checkpoint/input and comparable budget, a predicted distinguishing observation and a result that would disconfirm the interpretation. Advice is a proposal, not execution or authorization. Avoid generic advice that cannot distinguish the supplied explanations. Every summary, finding and next experiment must name the supplied episode_ids it draws on and cite the exact supporting source lines within those episodes' source_ids. Cite observations supporting the inference, not a future result. Cross-episode synthesis may combine the named episodes' evidence, but does not expand any individual metric's citation scope. Global sources outside those named episodes are not permitted. Do not infer causality from temporal adjacency, local proxy gains, a test count or a model verdict. Missing observations remain explicit limitations. Keep technical identifiers, numeric values, units, failures and uncertainty faithful to the sources.`; +// The revision is persisted in new requests. Omission replays the exact v1 +// instructions and identity, including language-specific historical jobs. +const SYNTHESIS_DECISION_INSTRUCTION = `${SYNTHESIS_INSTRUCTION} +Before proposing work, reconcile applicable completed comparisons, follow-up outcomes and the decision that retained or rejected the candidate. A missing excerpt does not establish that an experiment never happened. Prefer the unresolved condition or counterexample; justify a repeat when contemporary controls, noise or recovery make it informative. +Make the comparison legible: identify the baseline and candidate, input selection, actual budget and validity. Keep paired improvements, regressions and ties together when supplied; do not add overlapping cohorts or compare differences from different baselines as if paired. Preserve local-to-native transfer failures and distinguish best, final and retained results. +For a resource-limit explanation, distinguish a configured ceiling from attempted, completed and effective work. Ask whether the changed limit could actually bind in the observed execution. A run that ended before the old deadline is not positive evidence that relaxing that deadline helped; it can be an unexposed control. Missing phase-entry, stopping-reason or completed-work evidence remains unknown, not proof of either no effect or more exploration. +Use the existing controls, prediction and disconfirming_result fields to make the proposed test interpretable. Name only the operational checks needed to tell whether its intervention engaged and its comparison held, such as phase entry, actual exit reason and completed/effective work. Make predictions conditional on those checks; a failed manipulation is not a refutation of the mechanism. +Equal seeds or wall-clock limits alone do not establish matched random draws, proposal coverage or completed work. Where relevant, distinguish a fixed-work comparison from an end-to-end wall-clock comparison, and say how actual coupling will be checked. Identify intended post-intervention state/path divergence separately from a confound; do not demand identical later trajectories when changing those trajectories is the mechanism. Sampled proposals, accepted intermediate moves and direct final improvements are different observations; no direct update does not exclude an indirect path effect. +Keep findings and proposals concise and specific to the cited evidence. These are reasoning instructions, not extra measurements, automatic scientific qualification or permission to execute a test.`; const groundedSchema = { episode_ids: {type: 'array', minItems: 1, items: {type: 'string'}}, citations: {type: 'array', minItems: 1, items: citationSchema}, @@ -220,7 +229,12 @@ export function prepareTrajectoryAssessment(params: Record) { throw new EffectRuntimeRequestError('research_synthesis must be boolean'); } const synthesis = config.research_synthesis === true; - const method = synthesis ? SYNTHESIS_METHOD : TRAJECTORY_METHOD; + const synthesisRevision = config.research_synthesis_revision; + if (synthesisRevision !== undefined && (synthesisRevision !== 2 || !synthesis)) { + throw new EffectRuntimeRequestError('research_synthesis_revision requires enabled synthesis and supported revision 2'); + } + const synthesisInstruction = synthesisRevision === 2 ? SYNTHESIS_DECISION_INSTRUCTION : SYNTHESIS_INSTRUCTION; + const method = synthesis ? synthesisRevision === 2 ? 'research-episodes-synthesis-v2' : SYNTHESIS_METHOD : TRAJECTORY_METHOD; if (config.output_language !== undefined && config.output_language !== 'zh' && config.output_language !== 'en') { throw new EffectRuntimeRequestError('output_language must be zh or en'); } @@ -232,7 +246,8 @@ export function prepareTrajectoryAssessment(params: Record) { // Only analysis identity; provider configuration/execution is owned by its adapter. // Omission preserves the identity of already registered, language-unspecified artifacts. const configIdentity = {model: str(config.model, 'model'), effort: str(config.effort, 'effort'), service_tier: str(config.service_tier, 'service_tier'), - ...(language ? {output_language: language} : {}), ...(synthesis ? {research_synthesis: true} : {})}; + ...(language ? {output_language: language} : {}), ...(synthesis ? {research_synthesis: true} : {}), + ...(synthesisRevision === 2 ? {research_synthesis_revision: synthesisRevision} : {})}; const numberedSources = input.sources.map(s => ({...s, text: s.text.split('\n').map((line, n) => `${s.first_line+n}: ${line}`).join('\n')})); const languageInstruction = language ? `\n\nOutput language: ${language === 'zh' ? 'Simplified Chinese (简体中文)' : 'English'}. Write every reason, limitation and episode question in this language, regardless of the evidence language. Return episode_questions exactly once per supplied episode: episode_id unchanged and question as a concise faithful rendering of its research question. Preserve uncertainty, negation, comparisons, numbers and units. Do not add conclusions to questions. These are analytical renderings, not replacements for original records. @@ -252,7 +267,7 @@ Do not translate source identities, references, timestamps, statuses, measuremen const output_schema = synthesis ? {...assessmentSchema, required: [...assessmentSchema.required, 'synthesis'], properties: {...assessmentSchema.properties, synthesis: synthesisSchema}} : assessmentSchema; const analysis_key = createHash('sha256').update(JSON.stringify({method, rubric: RUBRIC, input, config: configIdentity, - ...(synthesis ? {synthesis_instruction: SYNTHESIS_INSTRUCTION} : {}), + ...(synthesis ? {synthesis_instruction: synthesisInstruction} : {}), ...(native ? {native_context_instruction: NATIVE_CONTEXT_INSTRUCTION} : {}), ...(language ? {language_instruction: languageInstruction} : {}), ...(display ? {display_method: display.schema_version, display_instruction: displayInstruction + literalValueInstruction + contextInstruction + quantityInstruction} : {})})).digest('hex'); @@ -266,7 +281,7 @@ Do not translate source identities, references, timestamps, statuses, measuremen ...(display ? {display_request: {schema_version: display.schema_version, snapshot_key: display.snapshot_key, source_id: display.source_id, source_namespace: display.source_namespace, eligible: display.fields.length}} : {}), ...(measured ? {native_outcomes: measured} : {}), - prompt: `${RUBRIC}${languageInstruction}${displayInstruction}${literalValueInstruction}${contextInstruction}${quantityInstruction}${synthesis ? SYNTHESIS_INSTRUCTION : ''}${native ? NATIVE_CONTEXT_INSTRUCTION : ''}\n\nEvidence pack:\n${JSON.stringify({...input, sources: promptSources})}${native ? `\n\nNative outcome context:\n${JSON.stringify(native)}` : ''}${display ? `\n\ndisplay_fields:\n${JSON.stringify(display.requests.map(field => ({field_id: field.field_id, text: field.protected.masked, + prompt: `${RUBRIC}${languageInstruction}${displayInstruction}${literalValueInstruction}${contextInstruction}${quantityInstruction}${synthesis ? synthesisInstruction : ''}${native ? NATIVE_CONTEXT_INSTRUCTION : ''}\n\nEvidence pack:\n${JSON.stringify({...input, sources: promptSources})}${native ? `\n\nNative outcome context:\n${JSON.stringify(native)}` : ''}${display ? `\n\ndisplay_fields:\n${JSON.stringify(display.requests.map(field => ({field_id: field.field_id, text: field.protected.masked, ...(field.context_citations ? {context_citations: field.context_citations} : {}), ...(display.literalContext ? {literals: field.protected.literals} : {}), ...(display.literalValues ? {explanation_numbers: field.protected.explanation_numbers} : {})})))}` : ''}`, diff --git a/tests/capabilities/test_trajectory_assessment_store.py b/tests/capabilities/test_trajectory_assessment_store.py index e2758a3ec6..f3a28bcbf1 100644 --- a/tests/capabilities/test_trajectory_assessment_store.py +++ b/tests/capabilities/test_trajectory_assessment_store.py @@ -686,6 +686,35 @@ def upgraded_method(record, output_language=None): inspect_pack(**scope) +def test_revised_producer_preserves_frozen_legacy_job_and_requires_new_execution(pack): + from loopx.capabilities.benchmark_toolkit.trajectory_assessment import prepare + + scope, operation, current, input_path, sources, provider = pack + assert current["config"]["research_synthesis_revision"] == 2 + prepared = prepare(input_path, model="gpt-6.1-sol", effort="max", service_tier="fast") + assert prepared["analysis_key"] == current["analysis_key"] + legacy = register_pack(**scope, input_path=input_path, source_root=sources, + title="Frozen legacy analysis", config={key: value for key, value in current["config"].items() + if key != "research_synthesis_revision"}) + legacy_operation = {**scope, "pack_id": legacy["pack_id"], "analysis_key": legacy["analysis_key"]} + execute_pack(**legacy_operation, codex=provider) + old = inspect_pack(**scope, pack_id=legacy["pack_id"]) + before = {path: path.read_bytes() for path in scope["runtime_root"].glob("**/jobs/*/*") if path.is_file()} + assert current["analysis_key"] != legacy["analysis_key"] + pending = inspect_pack(**scope, pack_id=current["pack_id"]) + assert pending["job"] is None and pending["result"] is None + execute_pack(**operation, codex=provider) + new = inspect_pack(**scope, pack_id=current["pack_id"]) + assert new["result"]["method_version"] == "research-episodes-synthesis-v2" + assert new["result"]["metrics"] == old["result"]["metrics"] + assert inspect_pack(**scope, pack_id=legacy["pack_id"]) == old + assert all(path.read_bytes() == content for path, content in before.items()) + # Both frozen versions still use the same literal, source-qualified reader. + for op in [legacy_operation, operation]: + assert read_citation(**op, source_id="source", first_line=2, last_line=2)["text"] == ( + "The native measurement did not improve utility · 原始测量。") + + def _usage_fixture(): def counts(i, c, o): return dict(input_tokens=i, cached_input_tokens=c, output_tokens=o) diff --git a/tests/control_plane_ts/trajectory_assessment.test.ts b/tests/control_plane_ts/trajectory_assessment.test.ts index 67ff4c443d..1341bd238e 100644 --- a/tests/control_plane_ts/trajectory_assessment.test.ts +++ b/tests/control_plane_ts/trajectory_assessment.test.ts @@ -496,11 +496,12 @@ test('artifact digests retain bare lowercase syntax, original bytes and error se } }); -test('research synthesis is grounded across named episodes without widening metric evidence', () => { +for (const revision of [undefined, 2]) test(`research synthesis revision ${revision ?? 1} preserves episode citation boundaries`, () => { const x = input(); x.sources.push({id: 'other', artifact: 'other.txt', artifact_sha256: 'b'.repeat(64), first_line: 4, text: 'A competing explanation remains open'}); x.episodes[1]!.source_ids = ['other']; - const p = prepareTrajectoryAssessment({input: x, config: {...config, research_synthesis: true, output_language: 'zh'}}); + const p = prepareTrajectoryAssessment({input: x, config: {...config, research_synthesis: true, output_language: 'zh', + ...(revision === 2 ? {research_synthesis_revision: revision} : {})}}); const citation = {source_id: 's', first_line: 10, last_line: 10}; const synthesis = { summary: {text: 'Negative evidence narrowed the question; the mechanism remains unresolved.', episode_ids: ['e1'], citations: [citation]}, @@ -544,3 +545,30 @@ test('synthesis off preserves legacy prompt, schema, key and qualified output', assert.equal(qualifyTrajectoryAssessment({...original, output: output()}).synthesis, undefined); assert.throws(() => prepareTrajectoryAssessment({input: input(), config: {...config, research_synthesis: 'yes'}}), /must be boolean/); }); + +test('synthesis revision separates new guidance while frozen and disabled requests keep their identities', () => { + const legacy = prepareTrajectoryAssessment({input: input(), config: {...config, research_synthesis: true}}); + // Frozen public fixture identities from before the revision: upgrading the + // producer must not strand saved jobs, including language-specific requests. + assert.equal(prepared().analysis_key, 'bafc1cd4c93f25a274a6f6789eda79f3a1f9e197ff21a651bfd7f677f36cb607'); + assert.equal(legacy.analysis_key, '96c07478771c8bdb4db5e79999a31a25f8acc86cbc182bc8cb09ad52ac8a7a91'); + const next = prepareTrajectoryAssessment({input: input(), config: {...legacy.config, research_synthesis_revision: 2}}); + assert.equal(next.method_version, 'research-episodes-synthesis-v2'); + assert.notEqual(next.analysis_key, legacy.analysis_key); + assert.deepEqual(next.input, legacy.input); + assert.deepEqual(next.output_schema, legacy.output_schema, 'No new result fields or scientific qualification gate'); + assert.equal(next.prompt.split('\n\nEvidence pack:\n')[1], legacy.prompt.split('\n\nEvidence pack:\n')[1]); + const raw = {...output(), synthesis: {summary: {text: 'The mechanism is unresolved.', episode_ids: ['e1'], + citations: [{source_id: 's', first_line: 10, last_line: 10}]}, findings: [], next_experiments: []}}; + assert.deepEqual(qualifyTrajectoryAssessment({...next, output: raw}).metrics, + qualifyTrajectoryAssessment({...legacy, output: raw}).metrics); + assert.equal(compareTrajectoryAssessments({...next, previous: {...legacy, output: raw}}).can_reuse, false, + 'A guidance revision needs an explicit new analysis; do not relabel a saved judgment'); + assert.deepEqual(prepareTrajectoryAssessment({input: input(), config: legacy.config}), legacy); + for (const revision of [null, true, '2', 0, 1, 3]) { + assert.throws(() => prepareTrajectoryAssessment({input: input(), config: {...config, + research_synthesis: true, research_synthesis_revision: revision}}), /supported revision/); + } + assert.throws(() => prepareTrajectoryAssessment({input: input(), config: {...config, + research_synthesis: false, research_synthesis_revision: 2}}), /enabled synthesis/); +});