tenseleyflow/loader / ad2304e

Browse files

Add typed evidence provenance core

Authored by espadonne
SHA
ad2304e9eac54db4448433967bf510cdca73066a
Parents
2671488
Tree
36ab49b

5 changed files

StatusFile+-
M src/loader/runtime/completion_trace.py 15 1
A src/loader/runtime/evidence_provenance.py 93 0
M src/loader/runtime/policy_timeline.py 3 0
M src/loader/runtime/workflow_policy.py 19 1
A tests/test_evidence_provenance.py 79 0
src/loader/runtime/completion_trace.pymodified
@@ -5,6 +5,11 @@ from __future__ import annotations
55
 from dataclasses import dataclass, field
66
 from typing import Any
77
 
8
+from .evidence_provenance import (
9
+    EvidenceProvenance,
10
+    normalize_evidence_provenance,
11
+    summarize_evidence_provenance,
12
+)
813
 from .workflow_policy import WorkflowTimelineEntry
914
 
1015
 
@@ -17,6 +22,7 @@ class CompletionTraceEntry:
1722
     decision_code: str
1823
     decision_summary: str
1924
     evidence_summary: list[str] = field(default_factory=list)
25
+    evidence_provenance: list[EvidenceProvenance] = field(default_factory=list)
2026
 
2127
     def to_dict(self) -> dict[str, str]:
2228
         """Serialize the entry into persisted session state."""
@@ -27,6 +33,7 @@ class CompletionTraceEntry:
2733
             "decision_code": self.decision_code,
2834
             "decision_summary": self.decision_summary,
2935
             "evidence_summary": list(self.evidence_summary),
36
+            "evidence_provenance": [item.to_dict() for item in self.evidence_provenance],
3037
         }
3138
 
3239
     @classmethod
@@ -43,6 +50,9 @@ class CompletionTraceEntry:
4350
                 for item in data.get("evidence_summary", [])
4451
                 if str(item).strip()
4552
             ],
53
+            evidence_provenance=normalize_evidence_provenance(
54
+                data.get("evidence_provenance")
55
+            ),
4656
         )
4757
 
4858
 
@@ -125,7 +135,11 @@ def _completion_trace_entry_from_timeline_entry(
125135
         outcome=entry.policy_outcome or _completion_outcome_from_kind(entry.kind),
126136
         decision_code=entry.reason_code,
127137
         decision_summary=summary,
128
-        evidence_summary=list(entry.evidence_summary),
138
+        evidence_summary=list(
139
+            entry.evidence_summary
140
+            or summarize_evidence_provenance(entry.evidence_provenance)
141
+        ),
142
+        evidence_provenance=list(entry.evidence_provenance),
129143
     )
130144
 
131145
 
src/loader/runtime/evidence_provenance.pyadded
@@ -0,0 +1,93 @@
1
+"""Typed evidence provenance carried through runtime policy decisions."""
2
+
3
+from __future__ import annotations
4
+
5
+from dataclasses import dataclass
6
+from enum import StrEnum
7
+from typing import Any
8
+
9
+
10
+class EvidenceProvenanceStatus(StrEnum):
11
+    """How one evidence item relates to a runtime decision."""
12
+
13
+    SUPPORTS = "supports"
14
+    MISSING = "missing"
15
+    CONTRADICTS = "contradicts"
16
+    CONTEXT = "context"
17
+
18
+
19
+@dataclass(slots=True)
20
+class EvidenceProvenance:
21
+    """One typed piece of evidence behind a completion or verification decision."""
22
+
23
+    category: str
24
+    source: str
25
+    summary: str
26
+    status: str = EvidenceProvenanceStatus.CONTEXT.value
27
+    subject: str | None = None
28
+    detail: str | None = None
29
+
30
+    def to_dict(self) -> dict[str, Any]:
31
+        """Serialize one provenance item for persisted runtime state."""
32
+
33
+        return {
34
+            "category": self.category,
35
+            "source": self.source,
36
+            "summary": self.summary,
37
+            "status": self.status,
38
+            "subject": self.subject,
39
+            "detail": self.detail,
40
+        }
41
+
42
+    @classmethod
43
+    def from_dict(cls, data: dict[str, Any]) -> EvidenceProvenance:
44
+        """Load one persisted provenance item."""
45
+
46
+        return cls(
47
+            category=str(data.get("category", "")),
48
+            source=str(data.get("source", "")),
49
+            summary=str(data.get("summary", "")),
50
+            status=str(data.get("status", EvidenceProvenanceStatus.CONTEXT.value)),
51
+            subject=_optional_text(data.get("subject")),
52
+            detail=_optional_text(data.get("detail")),
53
+        )
54
+
55
+    def render_summary(self) -> str:
56
+        """Render one concise human-facing summary."""
57
+
58
+        return self.summary
59
+
60
+
61
+def normalize_evidence_provenance(value: Any) -> list[EvidenceProvenance]:
62
+    """Coerce persisted provenance payloads into typed entries."""
63
+
64
+    if not isinstance(value, list):
65
+        return []
66
+    entries: list[EvidenceProvenance] = []
67
+    for item in value:
68
+        if isinstance(item, dict):
69
+            entries.append(EvidenceProvenance.from_dict(item))
70
+    return entries
71
+
72
+
73
+def summarize_evidence_provenance(
74
+    entries: list[EvidenceProvenance],
75
+    *,
76
+    max_items: int | None = None,
77
+) -> list[str]:
78
+    """Project typed provenance into concise evidence-summary strings."""
79
+
80
+    summaries: list[str] = []
81
+    limit = len(entries) if max_items is None else max_items
82
+    for entry in entries[:limit]:
83
+        summary = entry.render_summary().strip()
84
+        if summary and summary not in summaries:
85
+            summaries.append(summary)
86
+    return summaries
87
+
88
+
89
+def _optional_text(value: Any) -> str | None:
90
+    if value is None:
91
+        return None
92
+    text = str(value).strip()
93
+    return text or None
src/loader/runtime/policy_timeline.pymodified
@@ -4,6 +4,7 @@ from __future__ import annotations
44
 
55
 from .context import RuntimeContext
66
 from .events import TurnSummary
7
+from .evidence_provenance import EvidenceProvenance
78
 from .workflow_policy import (
89
     WorkflowDecisionKind,
910
     WorkflowTimelineEntry,
@@ -22,6 +23,7 @@ def append_policy_timeline_entry(
2223
     policy_outcome: str | None = None,
2324
     decision_kind: WorkflowDecisionKind | str | None = WorkflowDecisionKind.FORCED,
2425
     evidence_summary: list[str] | None = None,
26
+    evidence_provenance: list[EvidenceProvenance] | None = None,
2527
 ) -> WorkflowTimelineEntry:
2628
     """Append one typed completion/repair accountability event."""
2729
 
@@ -36,6 +38,7 @@ def append_policy_timeline_entry(
3638
         prompt_format=context.prompt_format,
3739
         prompt_sections=context.prompt_sections,
3840
         evidence_summary=evidence_summary,
41
+        evidence_provenance=evidence_provenance,
3942
     )
4043
     context.session.append_workflow_timeline_entry(entry)
4144
     summary.workflow_timeline = list(context.session.workflow_timeline)
src/loader/runtime/workflow_policy.pymodified
@@ -15,6 +15,11 @@ from .clarify_strategy import (
1515
     describe_clarify_pressure_kind,
1616
     describe_clarify_slot,
1717
 )
18
+from .evidence_provenance import (
19
+    EvidenceProvenance,
20
+    normalize_evidence_provenance,
21
+    summarize_evidence_provenance,
22
+)
1823
 from .workflow_signals import WorkflowSignalExtractor, WorkflowSignalPacket
1924
 
2025
 
@@ -293,6 +298,7 @@ class WorkflowTimelineEntry:
293298
     unresolved_questions: list[str] = field(default_factory=list)
294299
     signal_summary: list[str] = field(default_factory=list)
295300
     evidence_summary: list[str] = field(default_factory=list)
301
+    evidence_provenance: list[EvidenceProvenance] = field(default_factory=list)
296302
     clarify_stage: str | None = None
297303
     clarify_pressure_kind: str | None = None
298304
     pressure_pass_complete: bool = False
@@ -318,6 +324,9 @@ class WorkflowTimelineEntry:
318324
             "unresolved_questions": list(self.unresolved_questions),
319325
             "signal_summary": list(self.signal_summary),
320326
             "evidence_summary": list(self.evidence_summary),
327
+            "evidence_provenance": [
328
+                item.to_dict() for item in self.evidence_provenance
329
+            ],
321330
             "clarify_stage": self.clarify_stage,
322331
             "clarify_pressure_kind": self.clarify_pressure_kind,
323332
             "pressure_pass_complete": self.pressure_pass_complete,
@@ -345,6 +354,9 @@ class WorkflowTimelineEntry:
345354
             unresolved_questions=_string_list(data.get("unresolved_questions")),
346355
             signal_summary=_string_list(data.get("signal_summary")),
347356
             evidence_summary=_string_list(data.get("evidence_summary")),
357
+            evidence_provenance=normalize_evidence_provenance(
358
+                data.get("evidence_provenance")
359
+            ),
348360
             clarify_stage=_optional_text(data.get("clarify_stage")),
349361
             clarify_pressure_kind=_optional_text(data.get("clarify_pressure_kind")),
350362
             pressure_pass_complete=bool(data.get("pressure_pass_complete", False)),
@@ -389,6 +401,7 @@ class WorkflowTimelineEntry:
389401
             unresolved_questions=list(decision.unresolved_questions),
390402
             signal_summary=list(decision.signal_summary),
391403
             evidence_summary=list(decision.evidence_summary),
404
+            evidence_provenance=[],
392405
             clarify_stage=decision.clarify_stage,
393406
             clarify_pressure_kind=decision.clarify_pressure_kind,
394407
             pressure_pass_complete=decision.pressure_pass_complete,
@@ -413,6 +426,7 @@ class WorkflowTimelineEntry:
413426
         prompt_sections: list[str] | None = None,
414427
         signal_summary: list[str] | None = None,
415428
         evidence_summary: list[str] | None = None,
429
+        evidence_provenance: list[EvidenceProvenance] | None = None,
416430
         artifact_paths: list[str] | None = None,
417431
     ) -> WorkflowTimelineEntry:
418432
         """Build one typed non-routing accountability entry."""
@@ -424,6 +438,7 @@ class WorkflowTimelineEntry:
424438
             resolved_decision_kind = None
425439
         else:
426440
             resolved_decision_kind = str(decision_kind)
441
+        resolved_provenance = list(evidence_provenance or [])
427442
         return cls(
428443
             timestamp=_utc_now(),
429444
             kind=kind.value,
@@ -432,7 +447,10 @@ class WorkflowTimelineEntry:
432447
             summary=summary,
433448
             decision_kind=resolved_decision_kind,
434449
             signal_summary=list(signal_summary or []),
435
-            evidence_summary=list(evidence_summary or []),
450
+            evidence_summary=list(
451
+                evidence_summary or summarize_evidence_provenance(resolved_provenance)
452
+            ),
453
+            evidence_provenance=resolved_provenance,
436454
             policy_stage=policy_stage,
437455
             policy_outcome=policy_outcome,
438456
             prompt_format=prompt_format,
tests/test_evidence_provenance.pyadded
@@ -0,0 +1,79 @@
1
+"""Tests for typed evidence provenance on policy timelines and traces."""
2
+
3
+from __future__ import annotations
4
+
5
+from loader.runtime.completion_trace import completion_trace_from_workflow_timeline
6
+from loader.runtime.evidence_provenance import (
7
+    EvidenceProvenance,
8
+    EvidenceProvenanceStatus,
9
+)
10
+from loader.runtime.workflow_policy import WorkflowTimelineEntry, WorkflowTimelineEntryKind
11
+
12
+
13
+def test_workflow_timeline_entry_derives_evidence_summary_from_provenance() -> None:
14
+    entry = WorkflowTimelineEntry.accountability(
15
+        kind=WorkflowTimelineEntryKind.COMPLETION_FINALIZE,
16
+        mode="execute",
17
+        reason_code="continuation_budget_exhausted",
18
+        summary="completion: stopped because follow-through evidence was still missing",
19
+        policy_stage="continuation_check",
20
+        policy_outcome="finalize",
21
+        evidence_provenance=[
22
+            EvidenceProvenance(
23
+                category="verification",
24
+                source="dod.evidence",
25
+                summary="verification evidence was still missing for `pytest -q`",
26
+                status=EvidenceProvenanceStatus.MISSING.value,
27
+                subject="pytest -q",
28
+            )
29
+        ],
30
+    )
31
+
32
+    assert entry.evidence_summary == [
33
+        "verification evidence was still missing for `pytest -q`"
34
+    ]
35
+    assert entry.evidence_provenance[0].status == EvidenceProvenanceStatus.MISSING.value
36
+
37
+
38
+def test_completion_trace_projection_preserves_evidence_provenance() -> None:
39
+    timeline = [
40
+        WorkflowTimelineEntry.accountability(
41
+            kind=WorkflowTimelineEntryKind.COMPLETION_FINALIZE,
42
+            mode="execute",
43
+            reason_code="continuation_budget_exhausted",
44
+            summary="completion: stopped because follow-through evidence was still missing",
45
+            policy_stage="continuation_check",
46
+            policy_outcome="finalize",
47
+            evidence_provenance=[
48
+                EvidenceProvenance(
49
+                    category="verification",
50
+                    source="dod.evidence",
51
+                    summary="verification evidence was still missing for `pytest -q`",
52
+                    status=EvidenceProvenanceStatus.MISSING.value,
53
+                    subject="pytest -q",
54
+                ),
55
+                EvidenceProvenance(
56
+                    category="action",
57
+                    source="actions_taken",
58
+                    summary="recorded work already showed the requested edit happened",
59
+                    status=EvidenceProvenanceStatus.SUPPORTS.value,
60
+                ),
61
+            ],
62
+        )
63
+    ]
64
+
65
+    trace = completion_trace_from_workflow_timeline(
66
+        timeline,
67
+        last_decision_code="continuation_budget_exhausted",
68
+    )
69
+
70
+    assert len(trace) == 1
71
+    assert trace[0].decision_code == "continuation_budget_exhausted"
72
+    assert trace[0].evidence_summary == [
73
+        "verification evidence was still missing for `pytest -q`",
74
+        "recorded work already showed the requested edit happened",
75
+    ]
76
+    assert [item.status for item in trace[0].evidence_provenance] == [
77
+        EvidenceProvenanceStatus.MISSING.value,
78
+        EvidenceProvenanceStatus.SUPPORTS.value,
79
+    ]