dsh-math-modeling-agent 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -69,6 +69,46 @@ MathModelingAgent 把它改造成闭环:
69
69
  4. **证据验证**:独立重算、检查单位量纲、对照公式与代码、查找数据泄漏、比较基线、做敏感性分析、构造反例、查引用与可复现性。**证据不足就回去修正,而不是硬通过。**
70
70
  5. **结果交付**:说明哪些结论已支持、哪些有条件、哪些无法判断,以及如何复现、当前运行为何结束。
71
71
 
72
+ ## Chat-first 输出顺序
73
+
74
+ 每个重点环节都会先把完整、可读、可追问的文本直接输出在聊天正文中,再等待用户纠正或确认;确认后才写入 artifact,并允许 run-state 进入下一阶段。适用范围包括 D0/D-R、数据画像、全局假设、候选方向、SP1…、模型、计算结果、验证、鲁棒性、评价和 D4。
75
+
76
+ 如果某一阶段只生成了文件、工具摘要或一句结论,而没有在聊天中展示完整内容,它不算完成,真实 run 会停在当前阶段。
77
+
78
+ ## 模式、算力消耗与组合建议
79
+
80
+ 模式预算是一次 run 的执行上限,不代表一定会全部消耗。粗略可以用下面的模型理解总成本:
81
+
82
+ ```text
83
+ C_total ≈ A × (C_model + C_compute + C_verify) + Q × C_research
84
+ ```
85
+
86
+ - A:允许的尝试轮数;
87
+ - C_model:每轮模型分析、代码生成和报告整理的成本;
88
+ - C_compute:每轮数值计算、优化或仿真的成本;
89
+ - C_verify:每轮独立重算、反例、鲁棒性和一致性检查的成本;
90
+ - Q:文献/方法检索次数;
91
+ - C_research:每次检索、阅读和整理的成本。
92
+
93
+ 当前默认预算如下。计算预算只表示工具执行上限,不等同于模型 token 费用;实际消耗取决于问题复杂度、是否触发修正、工具耗时和证据缺口。
94
+
95
+ | 模式 | 最大尝试轮数 | 文献检索预算 | 计算预算上限 | 相对 Fast 的计算预算 |
96
+ | --- | ---: | ---: | ---: | ---: |
97
+ | Fast | 2 | 0 | 60 秒 | 1× |
98
+ | Standard | 12 | 12 | 1800 秒 | 30× |
99
+ | High-Assurance | 24 | 30 | 7200 秒 | 120× |
100
+
101
+ High-Assurance 还要求独立审计和更严格的终态门禁,因此通常比表中的计算预算比例更慢;但它不是“必然正确”,只是给验证、反例和修正留下更多预算。
102
+
103
+ ### 推荐组合
104
+
105
+ 1. **Fast → Standard**:先用低成本判断题目类型和可行方向,再对选定方向完整建模。适合普通练习题。
106
+ 2. **Standard → High-Assurance**:先完成 Q1 → Q2 → Q3,再只对最终候选做高保证复核。适合数学建模竞赛和论文交付,通常最划算。
107
+ 3. **Fast → Standard → High-Assurance**:先筛选,再完整求解,最后审计。适合问题复杂、验证成本高或需要提交论文的任务。
108
+ 4. **直接 High-Assurance**:只在结论风险高、问题依赖多或必须尽量减少遗漏时使用;不建议对尚未稳定的早期方向直接使用。
109
+
110
+ 模式在 run 初始化时确定。需要升档时创建新的 run 或 correction lineage,可以复用输入快照,但不能静默修改原 run 的历史结果。
111
+
72
112
  ## 核心:Claim → Obligation → Evidence
73
113
 
74
114
  **Claim(主张)**:影响结论的重要陈述,如"C1:该方案是全局最优解"、"C2:模型可泛化到训练数据之外"。
@@ -115,11 +155,11 @@ LEGACY_UNVERIFIED → DERIVED → EXECUTED → VERIFIED → INDEPENDENTLY_VERIFI
115
155
 
116
156
  **SOLVED 硬门禁**:范围冻结 + 必选 verification 通过并聚合为 `SATISFIED` 的 obligations + `SUPPORTED` claims + 关键对抗检查通过 + 可复现材料齐全 + 局限已声明;High-Assurance 还需独立审计(审计器只读产物,不依赖求解过程的私有推理)。
117
157
 
118
- ## 当前版本:v0.5.0 Evidence-driven pipeline
158
+ ## 当前版本:v0.5.1 Evidence-driven pipeline
119
159
 
120
160
  本版本新增:v3 run/ledger evidence graph;独立的 verification / obligation / claim / run 状态;inputs/raw 冻结快照和 SHA-256 校验;typed verification recipes;真实输出量化后的硬约束复检;九段科学报告、symbols 和 Answer Coverage;结构化失败启发;PROJECT_INITIAL_REVIEW 与 PAPER_FINAL_REVIEW 两种论文质量评审;以及不修改父 run 的 correction lineage。
121
161
 
122
- `PASS` 只表示某条 verification recipe 通过,不自动表示 claim `SUPPORTED` 或 run `SOLVED`。用户 waiver、未证明全局最优和部分证据只能进入 `CONDITIONAL`。CUMCM/MCM/ICM 评分是 paper-quality proxy,不是官方 CUMCM/COMAP 评分。0.4.1 仍可安装,v2 历史运行迁移后证据降级为 `LEGACY_UNVERIFIED`。
162
+ `PASS` 只表示某条 verification recipe 通过,不自动表示 claim `SUPPORTED` 或 run `SOLVED`。用户 waiver、未证明全局最优和部分证据只能进入 `CONDITIONAL`。CUMCM/MCM/ICM 评分是 paper-quality proxy,不是官方 CUMCM/COMAP 评分。
123
163
 
124
164
  ## 两个 Skill
125
165
 
@@ -172,7 +212,7 @@ D3 方向/D4 裁决),`run-state.mjs gate` 在每次状态转移前强制校
172
212
  安装:
173
213
 
174
214
  ```bash
175
- dsh plugin --profile web add github:yohanchen1/MathModelingAgent#v0.5.0
215
+ dsh plugin --profile web add github:yohanchen1/MathModelingAgent#v0.5.1
176
216
  dsh --profile web --dump-config # 检查组合层(应看到 dsh-math-modeling-agent-skills 行)
177
217
  dsh web # 重启以加载插件
178
218
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-math-modeling-agent",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Evidence-driven mathematical modeling and verification skills for DeepSeek Harness",
5
5
  "type": "module",
6
6
  "files": [
@@ -15,7 +15,7 @@ Move one mathematical problem from scoped intake to an evidence-backed conclusio
15
15
 
16
16
  Accept problem text, attachments, or an existing run directory.
17
17
 
18
- Interaction model (references/interaction-protocol.md): execution details stay autonomous (file names, tool adapters, state fields), but every model-shaping decision is a DEEP three-beat exchange — LLM presents a complete analysis with its evidence chain, the user responds with substance (corrections, background, their own references), the LLM updates the artifact and shows the delta. Decision points: D0 six-section restatement / D1 routing once per task, D-R literature research before any candidate is built, D2 assumptions and D3 direction per subproblem, D2' on mid-subproblem discoveries, D4 verdict per round. Show the runlog digest in the chat after every round (references/report-contract.md); announce one status line before every transition; the user may interrupt at any time.
18
+ Interaction model (references/interaction-protocol.md): execution details stay autonomous (file names, tool adapters, state fields), but every model-shaping decision is a DEEP three-beat exchange — LLM first presents the complete text in the chat body, the user responds with substance (corrections, background, their own references), then the LLM records the confirmed chat presentation, writes the artifact, and shows the delta. Decision points: D0 six-section restatement / D1 routing once per task, D-R literature research before any candidate is built, D2 assumptions and D3 direction per subproblem, D2' on mid-subproblem discoveries, D4 verdict per round. Every major phase (data profile, assumptions, claims, candidate, subproblem analysis, model, verification, robustness, evaluation and correction) follows the same chat-first rule. Show the runlog digest in the chat after every round (references/report-contract.md); announce one status line before every transition; the user may interrupt at any time.
19
19
 
20
20
  Return a concise current status, supported claims, unresolved obligations, attempt delta, artifact path, and next action or final report. The legacy Modeler → Analyzer → Correction behavior mapping is recorded in `references/original-project-parity.md`.
21
21
 
@@ -24,6 +24,33 @@ High-impact points (D0, D-R, D2) may loop beats 2-3 as many times as needed
24
24
  until the user says "可以了". Mathematical modeling is a deep activity;
25
25
  "skim and start solving" is a protocol violation.
26
26
 
27
+ ## Chat-first phase presentation (v0.5)
28
+
29
+ 每个重点环节必须先在聊天正文中展示完整、可读、可追问的文本,不能只写入 artifact,也不能只显示工具调用摘要。顺序固定为:
30
+
31
+ 1. LLM 在聊天中给出完整内容、依据链、当前结论和可能被推翻的条件;
32
+ 2. 给用户留下纠正、补充背景或确认的机会;
33
+ 3. 用户确认后记录 chat presentation(content hash、呈现时间、用户确认时间和先于 artifact 的顺序);
34
+ 4. 再写入 problem-brief、analysis log、assumptions、model、result 或 report artifact;
35
+ 5. 最后才允许 run-state transition。
36
+
37
+ 重点阶段与最小呈现内容如下:
38
+
39
+ | 阶段 | 聊天中必须完整展示 |
40
+ |---|---|
41
+ | D-R | 检索到的方法/背景、来源、适用范围和限制 |
42
+ | D0 / SCOPE_FROZEN | 六段式问题重述、依赖关系和待澄清问题 |
43
+ | INPUT_PROFILED | 每个附件的字段、单位、坐标系、范围和异常 |
44
+ | D2-G / CLAIMS_REGISTERED | 全局假设、依据、风险、Claims 和 Obligations |
45
+ | D3 / CANDIDATES_READY | 候选方向矩阵、选择理由、文献列和排除方向 |
46
+ | SP1… / ATTEMPT | 当前子问题的问题分析、输入、假设、目标和边界 |
47
+ | MODEL / EXECUTE | 完整模型方案、公式、参数、算法和预期输出 |
48
+ | VERIFICATION | 验证路径、反例、容差、结果和未覆盖范围 |
49
+ | EVALUATION / CORRECTION | 评价 finding、影响的 Claims、修正方案和 lineage |
50
+ | D4 / terminal | 本轮完整结果、证据摘要、局限和最终状态建议 |
51
+
52
+ run-state 只能验证结构化记录,不能直接观察聊天 UI;因此 chat presentation 是显式的 fail-closed 约束,而不是把写文件误认为已展示。缺失 presentation 时,真实 run 不得进入下一阶段。
53
+
27
54
  ## L1 decision points (MUST ask via the question tool)
28
55
 
29
56
  | Point | Trigger | Question | Default |
@@ -106,6 +133,12 @@ record to real files on disk.
106
133
 
107
134
  In v0.5, report/evaluation/terminal transitions also read answer-coverage.json when the v3 ledger has registered requirements. Missing or stale mappings are hard blockers; partial mappings and explicit waivers are conditional-only. EVALUATE and CORRECTION_REQUIRED are non-final lifecycle states. An evaluation finding creates a correction lineage and never rewrites the parent run or silently upgrades a claim.
108
135
 
136
+ ### Chat-first enforcement
137
+
138
+ For every major phase, the complete readable content must appear in the chat body before any phase artifact is written or state transition is attempted. The agent must show the evidence chain, current conclusion, limitations and falsification conditions, then invite user correction or confirmation. Only after confirmation may it call recordChatPresentation, write problem-brief/analysis/assumption/model/result/report artifacts, and invoke gate or transition.
139
+
140
+ A v3 real run has scope.chatFirstRequired=true and stores presentation records in ledger.scope.presentations. Each record must identify its stage, chat channel, content hash, presentedAt, userConfirmedAt, fullTextPresented=true and presentedBeforeArtifact=true. The machine gate cannot observe the UI directly, so it treats a missing or malformed record as a hard blocker and never treats a file-only artifact as proof of chat presentation. Major stages are D-R, D0, INPUT_PROFILED, D2-G, D3, each SPn, MODEL, VERIFICATION, EVALUATION, CORRECTION and D4.
141
+
109
142
  ## Phase pace — interactive beats between phases (v2.8)
110
143
 
111
144
  The exposition is delivered PHASE BY PHASE, never as a firehose. The phase
@@ -43,6 +43,15 @@
43
43
  "robustnessExempt": {
44
44
  "type": "boolean",
45
45
  "description": "True only for tasks with no perturbable parameters (pure proofs); terminal states otherwise require a PASSED robustness-kind obligation."
46
+ },
47
+ "chatFirstRequired": {
48
+ "type": "boolean",
49
+ "description": "Require complete chat presentation and user confirmation before each major phase transition."
50
+ },
51
+ "presentations": {
52
+ "type": "array",
53
+ "description": "Chat-first records: complete text presented, content hash, user confirmation and presentation order.",
54
+ "items": { "type": "object" }
46
55
  }
47
56
  }
48
57
  },
@@ -258,7 +258,7 @@ export function createGraph(taskId) {
258
258
  return {
259
259
  schemaVersion: 3,
260
260
  taskId,
261
- scope: { independentAuditPassed: false, interactions: [], decisionStack: [], robustnessExempt: false },
261
+ scope: { independentAuditPassed: false, interactions: [], decisionStack: [], robustnessExempt: false, chatFirstRequired: true, presentations: [] },
262
262
  requirements: [],
263
263
  assumptions: [],
264
264
  claims: [],
@@ -14,6 +14,12 @@ export const MODE_DEFAULTS = Object.freeze({
14
14
  standard: Object.freeze({ attempts: 12, researchQueries: 12, computeSeconds: 1800 }),
15
15
  'high-assurance': Object.freeze({ attempts: 24, researchQueries: 30, computeSeconds: 7200 }),
16
16
  })
17
+ export const CHAT_PHASE_REQUIREMENTS = Object.freeze({
18
+ SCOPE_FROZEN: 'D0', INPUT_PROFILED: 'INPUT_PROFILED', CLAIMS_REGISTERED: 'D2-G',
19
+ CANDIDATES_READY: 'D3', ATTEMPT: 'SUBPROBLEM', EXECUTE: 'MODEL', VERIFY: 'VERIFICATION',
20
+ EVALUATE: 'EVALUATION', CORRECTION_REQUIRED: 'CORRECTION', REVISE: 'CORRECTION',
21
+ SOLVED: 'D4', CONDITIONAL: 'D4', PARTIAL: 'D4', INCONCLUSIVE: 'D4', REFUTED: 'D4',
22
+ })
17
23
 
18
24
  const NON_FINAL_STATES = ['TRIAGE', 'SCOPE_FROZEN', 'INPUT_PROFILED', 'CLAIMS_REGISTERED', 'CANDIDATES_READY', 'ATTEMPT', 'EXECUTE', 'VERIFY', 'EVALUATE', 'CORRECTION_REQUIRED', 'REVISE', 'RESEARCH', 'FORK']
19
25
  const FINAL_STATES = ['SOLVED', 'PARTIAL', 'CONDITIONAL', 'INCONCLUSIVE', 'REFUTED', 'INFEASIBLE', 'UNIDENTIFIABLE', 'BLOCKED', 'CANCELLED']
@@ -383,7 +389,7 @@ function validateJournal(events, diagnostics) {
383
389
  })
384
390
  }
385
391
 
386
- export async function initRun(root, { taskId, mode = 'standard', budget, budgets, contract }, runtime = {}) {
392
+ export async function initRun(root, { taskId, mode = 'standard', budget, budgets, contract, chatFirstRequired = contract === true }, runtime = {}) {
387
393
  assertTaskId(taskId); assertMode(mode)
388
394
  const effectiveBudget = { ...MODE_DEFAULTS[mode], ...(budget ?? budgets ?? {}) }
389
395
  const budgetDiagnostics = []
@@ -398,7 +404,7 @@ export async function initRun(root, { taskId, mode = 'standard', budget, budgets
398
404
  }
399
405
  const ledger = {
400
406
  schemaVersion: SCHEMA_VERSION, taskId,
401
- scope: { independentAuditPassed: false, interactions: [], decisionStack: [], robustnessExempt: false, ...(contract === false ? { contractExempt: true } : {}) },
407
+ scope: { independentAuditPassed: false, interactions: [], decisionStack: [], robustnessExempt: false, chatFirstRequired: chatFirstRequired === true, presentations: [], ...(contract === false ? { contractExempt: true } : {}) },
402
408
  assumptions: [], claims: [], obligations: [], subproblems: [], candidates: [], issues: [],
403
409
  requirements: [], evidence: [], verifications: [], failures: [],
404
410
  }
@@ -521,12 +527,35 @@ function hasSubproblemD2(scope, subproblem) {
521
527
  return scope.interactions.some(entry => entry && entry.decisionPoint === 'D2' &&
522
528
  (Array.isArray(entry.scope) ? entry.scope.includes(subproblem) : entry.subproblem === subproblem))
523
529
  }
530
+ function presentationIsValid(record, stage, subproblem) {
531
+ if (!record || record.channel !== 'chat' || record.status !== 'PRESENTED' || record.fullTextPresented !== true || record.presentedBeforeArtifact !== true) return false
532
+ if (typeof record.id !== 'string' || record.id.trim() === '' || typeof record.contentHash !== 'string' || !/^[a-f0-9]{64}$/i.test(record.contentHash)) return false
533
+ if (typeof record.presentedAt !== 'string' || record.presentedAt.trim() === '' || typeof record.userConfirmedAt !== 'string' || record.userConfirmedAt.trim() === '') return false
534
+ if (stage === 'SUBPROBLEM' || stage === 'MODEL' || stage === 'VERIFICATION') return (record.stage === stage || (stage === 'SUBPROBLEM' && record.stage === subproblem)) && record.subproblem === subproblem
535
+ return record.stage === stage || (stage === 'SUBPROBLEM' && record.stage === subproblem)
536
+ }
537
+
538
+ function hasChatPresentation(scope, stage, subproblem) {
539
+ return Array.isArray(scope?.presentations) && scope.presentations.some(record => presentationIsValid(record, stage, subproblem))
540
+ }
541
+
542
+ function chatPresentationViolations(scope, target, subproblem) {
543
+ if (scope?.chatFirstRequired !== true) return []
544
+ const stage = CHAT_PHASE_REQUIREMENTS[target]
545
+ const required = target === 'SCOPE_FROZEN' ? ['D-R', 'D0'] : (stage ? [stage] : [])
546
+ const missing = required.filter(value => !(value === 'SUBPROBLEM' && !subproblem) && !hasChatPresentation(scope, value, subproblem))
547
+ const subject = subproblem ? ' for ' + subproblem : ''
548
+ return missing.map(value => 'chat presentation for ' + value + subject + ' is required before ' + target + ' (show the complete text in chat and obtain user confirmation)')
549
+ }
550
+
524
551
  function interactionContractViolations(run, ledger, to, reason, subproblem) {
525
552
  if (ledger.schemaVersion !== SCHEMA_VERSION) return []
526
553
  const scope = ledger.scope ?? {}
527
554
  if (scope.contractExempt === true) return []
528
555
  const violations = []
529
556
  const transitional = to !== undefined
557
+ const presentationTarget = transitional ? to : run.status
558
+ violations.push(...chatPresentationViolations(scope, presentationTarget, subproblem))
530
559
  const earlyStop = ['BLOCKED', 'CANCELLED'].includes(run.status)
531
560
  const statusOrder = ['TRIAGE', 'RESEARCH', 'SCOPE_FROZEN', 'INPUT_PROFILED', 'CLAIMS_REGISTERED', 'CANDIDATES_READY', 'ATTEMPT', 'EXECUTE', 'VERIFY', 'EVALUATE', 'CORRECTION_REQUIRED', 'REVISE', 'FORK']
532
561
  const reached = (target) => transitional
@@ -580,8 +609,9 @@ function interactionContractViolations(run, ledger, to, reason, subproblem) {
580
609
  }
581
610
  return violations
582
611
  }
583
- function enforceInteractionContract(run, ledger, to, reason) {
584
- const violations = interactionContractViolations(run, ledger, to, reason)
612
+ function enforceInteractionContract(run, ledger, to, reason, subproblem) {
613
+ if (ledger.scope?.chatFirstRequired !== true) return
614
+ const violations = interactionContractViolations(run, ledger, to, reason, subproblem)
585
615
  if (violations.length > 0) throw new Error(`interaction contract violated: ${violations.join('; ')}`)
586
616
  }
587
617
 
@@ -605,6 +635,7 @@ function validateTransitionInput(input) {
605
635
  if (input[key] !== undefined && (!Array.isArray(input[key]) || input[key].some(id => typeof id !== 'string'))) throw new Error(`${key} must be an array of strings`)
606
636
  }
607
637
  if (linkedIds(input).length === 0) throw new Error('transition requires at least one evidence or issue ID')
638
+ if (input.subproblem !== undefined && input.subproblem !== null && (typeof input.subproblem !== 'string' || input.subproblem.trim() === '')) throw new Error('subproblem must be a nonempty string or null')
608
639
  if (input.patch !== undefined && (!isObject(input.patch) || Object.keys(input.patch).some(key => key !== 'bestCandidateId'))) throw new Error('patch may only contain bestCandidateId')
609
640
  if (input.patch && (!Object.hasOwn(input.patch, 'bestCandidateId') || (input.patch.bestCandidateId !== null && typeof input.patch.bestCandidateId !== 'string'))) throw new Error('bestCandidateId must be a string or null')
610
641
  }
@@ -621,6 +652,39 @@ function validateAuthoritativeState(run, ledger, events) {
621
652
  if (diagnostics.length > 0) throw new Error(`invalid run state: ${diagnostics.join('; ')}`)
622
653
  }
623
654
 
655
+ const PRESENTATION_STAGES = new Set(['D-R', ...new Set(Object.values(CHAT_PHASE_REQUIREMENTS))])
656
+
657
+ function validateChatPresentationRecord(record) {
658
+ if (!isObject(record)) throw new TypeError('chat presentation must be an object')
659
+ for (const field of ['id', 'stage', 'channel', 'contentHash', 'presentedAt', 'userConfirmedAt', 'status']) {
660
+ if (typeof record[field] !== 'string' || record[field].trim() === '') throw new TypeError(field + ' is required')
661
+ }
662
+ if (!PRESENTATION_STAGES.has(record.stage) && !/^SP[0-9]+$/.test(record.stage)) throw new TypeError('chat presentation stage is invalid')
663
+ if (record.channel !== 'chat') throw new TypeError('chat presentation channel must be chat')
664
+ if (record.status !== 'PRESENTED') throw new TypeError('chat presentation status must be PRESENTED')
665
+ if (!/^[a-f0-9]{64}$/i.test(record.contentHash)) throw new TypeError('chat presentation contentHash must be SHA-256 hex')
666
+ if (record.fullTextPresented !== true) throw new TypeError('fullTextPresented must be true')
667
+ if (record.presentedBeforeArtifact !== true) throw new TypeError('presentedBeforeArtifact must be true')
668
+ if (['SUBPROBLEM', 'MODEL', 'VERIFICATION'].includes(record.stage) && (typeof record.subproblem !== 'string' || record.subproblem.trim() === '')) throw new TypeError(record.stage + ' presentation requires subproblem')
669
+ return record
670
+ }
671
+
672
+ export async function recordChatPresentation(root, record, runtime = {}) {
673
+ validateChatPresentationRecord(record)
674
+ return withLock(root, MUTATION_LOCK_FILE, 'chat-presentation', async () => {
675
+ const p = paths(root)
676
+ const ledger = await readJson(p.ledger)
677
+ if (ledger.schemaVersion !== SCHEMA_VERSION) throw new Error('chat presentations require a v3 ledger')
678
+ ledger.scope ??= {}
679
+ ledger.scope.presentations ??= []
680
+ if (!Array.isArray(ledger.scope.presentations)) throw new Error('ledger.scope.presentations must be an array')
681
+ if (ledger.scope.presentations.some(value => value?.id === record.id)) throw new Error('duplicate chat presentation id: ' + record.id)
682
+ ledger.scope.presentations.push(clone(record))
683
+ await atomicWrite(p.ledger, ledger, runtime)
684
+ return clone(record)
685
+ }, runtime)
686
+ }
687
+
624
688
  export async function transitionRun(root, input, runtime = {}) {
625
689
  validateTransitionInput(input)
626
690
  return withLock(root, MUTATION_LOCK_FILE, 'transition', async () => {
@@ -631,6 +695,7 @@ export async function transitionRun(root, input, runtime = {}) {
631
695
  validateAuthoritativeState(run, ledger, events)
632
696
  if (!isAllowedTransition(run.status, input.to)) throw new Error(illegalTransitionMessage(run.status, input.to))
633
697
  if (input.to === 'SOLVED') enforceSolvedGate(run, ledger)
698
+ enforceInteractionContract(run, ledger, input.to, input.reason, input.subproblem)
634
699
  const timestamp = now()
635
700
  const bestCandidateId = input.patch && Object.hasOwn(input.patch, 'bestCandidateId') ? input.patch.bestCandidateId : run.bestCandidateId
636
701
  const next = { ...run, status: input.to, currentAttempt: nextAttempt(run.currentAttempt, input.to), eventSequence: run.eventSequence + 1, bestCandidateId, updatedAt: timestamp }
@@ -814,7 +879,7 @@ async function cli(argv) {
814
879
  process.exitCode = 1
815
880
  return { error: `interaction contract violated: ${gate.violations.join('; ')}`, gate }
816
881
  }
817
- return transitionRun(root, { to: options.to, reason: options.reason, evidenceIds: options.evidence ? options.evidence.split(',').filter(Boolean) : [], issueIds: options.issues ? options.issues.split(',').filter(Boolean) : [], patch: options.candidate ? { bestCandidateId: options.candidate } : undefined })
882
+ return transitionRun(root, { to: options.to, reason: options.reason, subproblem: options.subproblem, evidenceIds: options.evidence ? options.evidence.split(',').filter(Boolean) : [], issueIds: options.issues ? options.issues.split(',').filter(Boolean) : [], patch: options.candidate ? { bestCandidateId: options.candidate } : undefined })
818
883
  }
819
884
  if (command === 'validate') {
820
885
  const result = await validateRun(root, options['task-id'])