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
This commit is contained in:
+74
-7
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 = "图片内容未纳入"
|
||||
|
||||
+3
-1
@@ -419,7 +419,9 @@ export interface TaskSnapshot {
|
||||
checkpointKeys?: Record<string,string>; 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 }
|
||||
|
||||
Reference in New Issue
Block a user