From c722c16626564a44b060cfff8ced3b27b8ea3c09 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 16:03:33 +0000 Subject: [PATCH] Validate optional evidence in work checkpoint candidates Why: U3 needs bounded, source-indexed premises before the host can judge numeric and membership claims. Evidence: TestWorkCheckpointEvidenceModesAndBounds failed on missing types, then go test ./... -count=1 and git diff --check passed. Impact: Extend only the candidate wire contract; persisted task snapshots and display kinds stay unchanged. Refs: Dev task feature/fast-slow-u2-u3-plan; r0.3 evidence implementation plan Task 1 --- task_snapshot.go | 81 +++++++++++++++++++++++++++++++++++++++---- task_snapshot_test.go | 48 +++++++++++++++++++++++++ ts/types.ts | 4 ++- 3 files changed, 125 insertions(+), 8 deletions(-) diff --git a/task_snapshot.go b/task_snapshot.go index 58e41fd..3144618 100644 --- a/task_snapshot.go +++ b/task_snapshot.go @@ -98,13 +98,77 @@ type WorkCheckpointCandidate struct { Findings []WorkFindingCandidate `json:"findings"` } type WorkFindingCandidate struct { - FindingKey string `json:"findingKey"` - Text string `json:"text"` - Impact string `json:"impact,omitempty"` - Kind string `json:"kind"` - Assumptions string `json:"assumptions,omitempty"` - Corrects string `json:"corrects,omitempty"` - Sources []TaskSourceQuote `json:"sources"` + FindingKey string `json:"findingKey"` + Text string `json:"text"` + Impact string `json:"impact,omitempty"` + Kind string `json:"kind"` + Assumptions string `json:"assumptions,omitempty"` + Corrects string `json:"corrects,omitempty"` + Sources []TaskSourceQuote `json:"sources"` + Evidence *WorkFindingEvidence `json:"evidence,omitempty"` +} + +// WorkFindingEvidence is a model-supplied candidate, not a persisted proof. +// Backend checks the quoted source text and computes any resulting statement. +type WorkEvidencePremise struct { + Role string `json:"role"` + SourceIndex int `json:"sourceIndex"` + Value *int `json:"value,omitempty"` +} + +type WorkFindingEvidence struct { + Mode string `json:"mode"` + Premises []WorkEvidencePremise `json:"premises"` + Result *int `json:"result,omitempty"` +} + +func validateWorkFindingEvidence(e *WorkFindingEvidence, sourceCount int) error { + if e == nil { + return nil + } + var required []string + switch e.Mode { + case "direct_relation": + required = []string{"direct_relation"} + if e.Result != nil { + return fmt.Errorf("checkpoint.evidence: direct relation cannot have result") + } + case "difference": + required = []string{"total", "phase"} + if e.Result == nil || *e.Result < 0 || *e.Result > 1000000 { + return fmt.Errorf("checkpoint.evidence: difference requires bounded result") + } + case "unresolved_relation": + required = []string{"total", "phase", "dependent"} + if e.Result != nil { + return fmt.Errorf("checkpoint.evidence: unresolved relation cannot have result") + } + default: + return fmt.Errorf("checkpoint.evidence: unknown mode") + } + if len(e.Premises) != len(required) { + return fmt.Errorf("checkpoint.evidence: wrong premise count") + } + roles := make(map[string]bool, len(required)) + for _, premise := range e.Premises { + if roles[premise.Role] || premise.SourceIndex < 0 || premise.SourceIndex >= sourceCount { + return fmt.Errorf("checkpoint.evidence: duplicate role or invalid source index") + } + roles[premise.Role] = true + if e.Mode == "direct_relation" { + if premise.Value != nil { + return fmt.Errorf("checkpoint.evidence: direct relation cannot have value") + } + } else if premise.Value == nil || *premise.Value < 0 || *premise.Value > 1000000 { + return fmt.Errorf("checkpoint.evidence: count premise requires bounded value") + } + } + for _, role := range required { + if !roles[role] { + return fmt.Errorf("checkpoint.evidence: missing premise role") + } + } + return nil } // Validate enforces wire limits; owner/source authorization belongs to Backend. @@ -203,6 +267,9 @@ func ValidateWorkCheckpoint(c WorkCheckpointCandidate) error { return fmt.Errorf("checkpoint.source: invalid") } } + if err := validateWorkFindingEvidence(f.Evidence, len(f.Sources)); err != nil { + return err + } if (f.Kind == "analysis" || f.Kind == "provisional") && strings.TrimSpace(f.Assumptions) == "" { return fmt.Errorf("checkpoint.assumptions: required") } diff --git a/task_snapshot_test.go b/task_snapshot_test.go index 309d4ba..a0de869 100644 --- a/task_snapshot_test.go +++ b/task_snapshot_test.go @@ -72,6 +72,54 @@ func TestWorkCheckpointRejectsVerifiedAndEmptyEvidence(t *testing.T) { t.Fatal("unbounded candidate accepted") } } + +func TestWorkCheckpointEvidenceModesAndBounds(t *testing.T) { + base := func() WorkCheckpointCandidate { + return WorkCheckpointCandidate{CheckpointID: "cp-evidence", PhaseID: "crosscheck", Findings: []WorkFindingCandidate{{ + FindingKey: "phase", Text: "待核对", Kind: "needs_user", + Sources: []TaskSourceQuote{{SourceID: "total", Quote: "总12项"}, {SourceID: "phase", Quote: "一期8项"}, {SourceID: "dependent", Quote: "其中3项依赖接口"}}, + }}} + } + n := func(v int) *int { return &v } + total := WorkEvidencePremise{Role: "total", SourceIndex: 0, Value: n(12)} + phase := WorkEvidencePremise{Role: "phase", SourceIndex: 1, Value: n(8)} + dependent := WorkEvidencePremise{Role: "dependent", SourceIndex: 2, Value: n(3)} + for _, tc := range []struct { + name string + evidence WorkFindingEvidence + valid bool + }{ + {"direct", WorkFindingEvidence{Mode: "direct_relation", Premises: []WorkEvidencePremise{{Role: "direct_relation", SourceIndex: 0}}}, true}, + {"difference", WorkFindingEvidence{Mode: "difference", Premises: []WorkEvidencePremise{total, phase}, Result: n(4)}, true}, + {"unresolved", WorkFindingEvidence{Mode: "unresolved_relation", Premises: []WorkEvidencePremise{total, phase, dependent}}, true}, + {"unknown mode", WorkFindingEvidence{Mode: "prediction", Premises: []WorkEvidencePremise{total, phase}}, false}, + {"missing result", WorkFindingEvidence{Mode: "difference", Premises: []WorkEvidencePremise{total, phase}}, false}, + {"extra result", WorkFindingEvidence{Mode: "unresolved_relation", Premises: []WorkEvidencePremise{total, phase, dependent}, Result: n(3)}, false}, + {"duplicate role", WorkFindingEvidence{Mode: "difference", Premises: []WorkEvidencePremise{total, total}, Result: n(4)}, false}, + {"missing premise", WorkFindingEvidence{Mode: "unresolved_relation", Premises: []WorkEvidencePremise{total, phase}}, false}, + {"negative index", WorkFindingEvidence{Mode: "direct_relation", Premises: []WorkEvidencePremise{{Role: "direct_relation", SourceIndex: -1}}}, false}, + {"foreign index", WorkFindingEvidence{Mode: "direct_relation", Premises: []WorkEvidencePremise{{Role: "direct_relation", SourceIndex: 3}}}, false}, + {"negative value", WorkFindingEvidence{Mode: "difference", Premises: []WorkEvidencePremise{{Role: "total", SourceIndex: 0, Value: n(-1)}, phase}, Result: n(4)}, false}, + {"large value", WorkFindingEvidence{Mode: "difference", Premises: []WorkEvidencePremise{{Role: "total", SourceIndex: 0, Value: n(1000001)}, phase}, Result: n(4)}, false}, + {"direct value", WorkFindingEvidence{Mode: "direct_relation", Premises: []WorkEvidencePremise{{Role: "direct_relation", SourceIndex: 0, Value: n(1)}}}, false}, + {"missing value", WorkFindingEvidence{Mode: "difference", Premises: []WorkEvidencePremise{{Role: "total", SourceIndex: 0}, phase}, Result: n(4)}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + c := base() + c.Findings[0].Evidence = &tc.evidence + err := ValidateWorkCheckpoint(c) + if (err == nil) != tc.valid { + t.Fatalf("valid=%v err=%v", tc.valid, err) + } + }) + } + c := base() + c.Findings[0].Evidence = &WorkFindingEvidence{Mode: "difference", Premises: []WorkEvidencePremise{total, phase}, Result: n(4)} + c.Findings[0].Text = strings.Repeat("字", 5500) + if err := ValidateWorkCheckpoint(c); err == nil || !strings.Contains(err.Error(), "16KiB") { + t.Fatal("evidence bypassed checkpoint size cap", err) + } +} func TestTaskSnapshotBodyPreservesLimitsAndHonestStates(t *testing.T) { s := validTaskSnapshot() s.Remaining = "图片内容未纳入" diff --git a/ts/types.ts b/ts/types.ts index f73d163..cfb6024 100644 --- a/ts/types.ts +++ b/ts/types.ts @@ -419,7 +419,9 @@ export interface TaskSnapshot { checkpointKeys?: Record; checkpointCount?: number; checkpointSeq?: number; } export interface TaskSourceQuote { sourceId: string; blockIndex: number; quote: string } -export interface WorkFindingCandidate { findingKey: string; text: string; impact?: string; kind: string; assumptions?: string; corrects?: string; sources: TaskSourceQuote[] } +export interface WorkEvidencePremise { role: "total" | "phase" | "dependent" | "direct_relation"; sourceIndex: number; value?: number } +export interface WorkFindingEvidence { mode: "direct_relation" | "difference" | "unresolved_relation"; premises: WorkEvidencePremise[]; result?: number } +export interface WorkFindingCandidate { findingKey: string; text: string; impact?: string; kind: string; assumptions?: string; corrects?: string; sources: TaskSourceQuote[]; evidence?: WorkFindingEvidence } export interface WorkCheckpointCandidate { checkpointId: string; phaseId: string; focus?: string; remaining?: string; nextStep?: string; findings: WorkFindingCandidate[] } export interface UIAction { id: string; label: string; style?: string; command?: string }