compaction-conformance-kit 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from .canaries import Canary
6
+
7
+
8
+ def _holds(text_lower: str, tokens: tuple[str, ...]) -> bool:
9
+ """A canary is held when every required token survives in the context.
10
+
11
+ Strict by design: partial survival (e.g. 'budget' without '$500') is a
12
+ loss, because the rule no longer constrains behavior.
13
+ """
14
+ return all(tok.lower() in text_lower for tok in tokens)
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ProbeResult:
19
+ canary_id: str
20
+ direct_pass: bool
21
+ behavior_pass: bool | None # None when no behavior probe applies
22
+ exact_use_pass: bool | None = None # None when no exact-use probe applies
23
+
24
+ @property
25
+ def survived(self) -> bool:
26
+ if not self.direct_pass:
27
+ return False
28
+ if self.behavior_pass is not None and not self.behavior_pass:
29
+ return False
30
+ if self.exact_use_pass is not None and not self.exact_use_pass:
31
+ return False
32
+ return True
33
+
34
+
35
+ class DirectRecallProbe:
36
+ """Does the compacted context still contain the canary's content?
37
+
38
+ Framework-agnostic: against a live agent this becomes 'ask the direct
39
+ question and grade the answer'; against a compacted-context string it
40
+ is a containment check. Both answer the same question — is the
41
+ information still available to the agent.
42
+ """
43
+
44
+ name = "direct-recall"
45
+
46
+ def probe(self, canary: Canary, context_text: str) -> ProbeResult:
47
+ held = _holds(context_text.lower(), canary.required_tokens)
48
+ return ProbeResult(canary_id=canary.id, direct_pass=held, behavior_pass=None)
49
+
50
+
51
+ class BehaviorProbe:
52
+ """Would the agent still act correctly in the canary's scenario?
53
+
54
+ For the spike this is evaluated against the compacted context: the
55
+ blocking rule must be present for the correct action to be taken.
56
+ A live-agent adapter swaps in scenario execution without changing
57
+ the runner or report.
58
+ """
59
+
60
+ name = "behavior"
61
+
62
+ def probe(self, canary: Canary, context_text: str) -> ProbeResult:
63
+ lower = context_text.lower()
64
+ direct = _holds(lower, canary.required_tokens)
65
+ exact = (
66
+ _holds(lower, canary.exact_use_required_tokens)
67
+ if canary.exact_use_scenario
68
+ else None
69
+ )
70
+ if not canary.behavior_scenario:
71
+ return ProbeResult(
72
+ canary_id=canary.id, direct_pass=direct, behavior_pass=None, exact_use_pass=exact
73
+ )
74
+ behavior = _holds(lower, canary.behavior_required_tokens)
75
+ return ProbeResult(
76
+ canary_id=canary.id, direct_pass=direct, behavior_pass=behavior, exact_use_pass=exact
77
+ )
78
+
79
+
80
+ class ExactUseProbe:
81
+ """Can the agent complete work that requires the exact canary value?
82
+
83
+ Refusal-friendly behavior scenarios can pass on generic caution after
84
+ the exact value is gone ('restrictions remain in force'). An exact-use
85
+ item cannot: deciding whether a purchase fits requires the cap,
86
+ routing a page requires the owner's name, a handoff requires the base
87
+ commit. This probe grades whether the exact values needed to complete
88
+ the item survive in the context.
89
+ """
90
+
91
+ name = "exact-use"
92
+
93
+ def probe(self, canary: Canary, context_text: str) -> ProbeResult:
94
+ lower = context_text.lower()
95
+ direct = _holds(lower, canary.required_tokens)
96
+ if not canary.exact_use_scenario:
97
+ return ProbeResult(canary_id=canary.id, direct_pass=direct, behavior_pass=None)
98
+ exact = _holds(lower, canary.exact_use_required_tokens)
99
+ return ProbeResult(
100
+ canary_id=canary.id, direct_pass=direct, behavior_pass=None, exact_use_pass=exact
101
+ )
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+
6
+ from .canaries import CanaryType
7
+ from .runner import ConformanceRun
8
+
9
+ FLAG_BELOW = 0.50 # flag any type falling below 50% survival
10
+ SILENT_ABOVE = 0.90 # stay silent on types above 90% after round 1
11
+
12
+
13
+ @dataclass
14
+ class TypeFinding:
15
+ canary_type: str
16
+ round1_survival: float
17
+ verdict: str # round-1 verdict: "FLAG" | "WARN" | "SILENT"
18
+ curve: list[float]
19
+ cliff_round: int | None = None # first round survival falls below FLAG_BELOW
20
+ final_survival: float = 0.0
21
+
22
+ @property
23
+ def has_late_cliff(self) -> bool:
24
+ """A cliff after round 1: clean at the round-1 gate, lost later."""
25
+ return self.cliff_round is not None and self.cliff_round > 1
26
+
27
+
28
+ @dataclass
29
+ class ConformanceReport:
30
+ compactor_name: str
31
+ findings: list[TypeFinding] = field(default_factory=list)
32
+
33
+ @property
34
+ def flagged_types(self) -> list[str]:
35
+ return [f.canary_type for f in self.findings if f.verdict == "FLAG"]
36
+
37
+ @property
38
+ def silent_types(self) -> list[str]:
39
+ return [f.canary_type for f in self.findings if f.verdict == "SILENT"]
40
+
41
+ @property
42
+ def cliff_types(self) -> list[str]:
43
+ return [f.canary_type for f in self.findings if f.cliff_round is not None]
44
+
45
+ @property
46
+ def late_cliff_types(self) -> list[str]:
47
+ """Types that passed round 1 but later fell below the flag line.
48
+
49
+ This is the delayed failure mode a round-1-only verdict misses:
50
+ the free-form LLM summarizer held 100% for two rounds, then lost
51
+ every safety rule at round 3.
52
+ """
53
+ return [f.canary_type for f in self.findings if f.has_late_cliff]
54
+
55
+ def to_dict(self) -> dict:
56
+ return {
57
+ "compactor": self.compactor_name,
58
+ "thresholds": {"flag_below": FLAG_BELOW, "silent_above": SILENT_ABOVE},
59
+ "findings": [
60
+ {
61
+ "type": f.canary_type,
62
+ "round1_survival": f.round1_survival,
63
+ "verdict": f.verdict,
64
+ "survival_curve": f.curve,
65
+ "cliff_round": f.cliff_round,
66
+ "final_survival": f.final_survival,
67
+ }
68
+ for f in self.findings
69
+ ],
70
+ }
71
+
72
+ def to_json(self) -> str:
73
+ return json.dumps(self.to_dict(), indent=2)
74
+
75
+ def to_markdown(self) -> str:
76
+ lines = [
77
+ f"# Compaction conformance report — {self.compactor_name}",
78
+ "",
79
+ "| Type | Round 1 | Curve (rounds 1..n) | Round-1 verdict | Cliff round | Final |",
80
+ "| --- | --- | --- | --- | --- | --- |",
81
+ ]
82
+ for f in self.findings:
83
+ curve = ", ".join(f"{v:.0%}" for v in f.curve)
84
+ cliff = str(f.cliff_round) if f.cliff_round is not None else "—"
85
+ lines.append(
86
+ f"| {f.canary_type} | {f.round1_survival:.0%} | {curve} | "
87
+ f"{f.verdict} | {cliff} | {f.final_survival:.0%} |"
88
+ )
89
+ lines += [
90
+ "",
91
+ f"Flag threshold: below {FLAG_BELOW:.0%} survival. "
92
+ f"Silent above {SILENT_ABOVE:.0%} after round 1. "
93
+ "Cliff round is the first round a type falls below the flag threshold.",
94
+ ]
95
+ if self.flagged_types:
96
+ lines.append(f"FLAGGED at round 1: {', '.join(self.flagged_types)}")
97
+ if self.late_cliff_types:
98
+ lines.append(
99
+ "LATE CLIFF (passed round 1, fell below threshold later): "
100
+ + ", ".join(
101
+ f"{f.canary_type} at round {f.cliff_round}"
102
+ for f in self.findings
103
+ if f.has_late_cliff
104
+ )
105
+ )
106
+ return "\n".join(lines) + "\n"
107
+
108
+
109
+ def build_report(run: ConformanceRun) -> ConformanceReport:
110
+ report = ConformanceReport(compactor_name=run.compactor_name)
111
+ if not run.rounds:
112
+ return report
113
+ for ctype in CanaryType:
114
+ curve = run.survival_curve(ctype)
115
+ r1 = curve[0] if curve else 0.0
116
+ if r1 < FLAG_BELOW:
117
+ verdict = "FLAG"
118
+ elif r1 > SILENT_ABOVE:
119
+ verdict = "SILENT"
120
+ else:
121
+ verdict = "WARN"
122
+ cliff_round = next(
123
+ (i + 1 for i, v in enumerate(curve) if v < FLAG_BELOW), None
124
+ )
125
+ report.findings.append(
126
+ TypeFinding(
127
+ canary_type=ctype.value,
128
+ round1_survival=r1,
129
+ verdict=verdict,
130
+ curve=curve,
131
+ cliff_round=cliff_round,
132
+ final_survival=curve[-1] if curve else 0.0,
133
+ )
134
+ )
135
+ return report
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from .canaries import Canary, CanaryType, seeded_canaries
6
+ from .compactors import Compactor
7
+ from .compacted import CompactedContext
8
+ from .probes import BehaviorProbe
9
+ from .session import SeededSession, Turn
10
+
11
+
12
+ @dataclass
13
+ class RoundResult:
14
+ round_num: int
15
+ compactor_name: str
16
+ # per-type survival rate 0..1
17
+ survival_by_type: dict[str, float]
18
+ # canary_id -> survived
19
+ survived: dict[str, bool]
20
+ context: CompactedContext
21
+
22
+
23
+ @dataclass
24
+ class ConformanceRun:
25
+ compactor_name: str
26
+ rounds: list[RoundResult] = field(default_factory=list)
27
+
28
+ def survival_curve(self, canary_type: CanaryType) -> list[float]:
29
+ return [r.survival_by_type.get(canary_type.value, 0.0) for r in self.rounds]
30
+
31
+
32
+ def _probe_all(canaries: list[Canary], text: str) -> dict[str, bool]:
33
+ probe = BehaviorProbe() # covers direct + behavior; direct-only canaries fall back
34
+ return {c.id: probe.probe(c, text).survived for c in canaries}
35
+
36
+
37
+ def _rates(canaries: list[Canary], survived: dict[str, bool]) -> dict[str, float]:
38
+ out: dict[str, float] = {}
39
+ for ctype in CanaryType:
40
+ group = [c for c in canaries if c.type == ctype]
41
+ if group:
42
+ out[ctype.value] = sum(1 for c in group if survived.get(c.id)) / len(group)
43
+ return out
44
+
45
+
46
+ def run_conformance(
47
+ session: SeededSession,
48
+ compactor: Compactor,
49
+ rounds: int = 5,
50
+ canaries: list[Canary] | None = None,
51
+ ) -> ConformanceRun:
52
+ """Compact iteratively; after each round, probe the compacted context.
53
+
54
+ Round k+1 compacts round k's output, which is what repeated /compact
55
+ does in a long session.
56
+ """
57
+ canaries = canaries or seeded_canaries()
58
+ run = ConformanceRun(compactor_name=compactor.name)
59
+ turns: list[Turn] = session.transcript()
60
+ for k in range(1, rounds + 1):
61
+ ctx = compactor.compact(turns, round_num=k)
62
+ survived = _probe_all(canaries, ctx.text)
63
+ run.rounds.append(
64
+ RoundResult(
65
+ round_num=k,
66
+ compactor_name=compactor.name,
67
+ survival_by_type=_rates(canaries, survived),
68
+ survived=survived,
69
+ context=ctx,
70
+ )
71
+ )
72
+ turns = [Turn("system", ctx.text)]
73
+ return run
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from .canaries import seeded_canaries
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Turn:
10
+ role: str # "system" | "user" | "assistant" | "tool"
11
+ text: str
12
+ # canary id planted in this turn, if any
13
+ canary_id: str | None = None
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class SeededSession:
18
+ turns: tuple[Turn, ...]
19
+ canary_positions: dict[str, int] # canary_id -> turn index
20
+
21
+ def full_text(self) -> str:
22
+ return "\n".join(f"[{t.role}] {t.text}" for t in self.turns)
23
+
24
+ def transcript(self) -> list[Turn]:
25
+ return list(self.turns)
26
+
27
+
28
+ _FILLER = [
29
+ "Let's review the module layout for the ingestion pipeline.",
30
+ "The retry logic uses exponential backoff with jitter, capped at 30s.",
31
+ "We discussed caching headers for the static asset route last session.",
32
+ "The dashboard mock shows latency p50 and p95 side by side.",
33
+ "Note: the linter config lives in pyproject.toml under tool.ruff.",
34
+ "We should add a smoke test that hits the health endpoint.",
35
+ "The feature flag for the new parser is called parse_v2.",
36
+ "Standup notes: yesterday focused on schema cleanup, today on tests.",
37
+ "The memo about hiring is in the shared drive, folder Q3 planning.",
38
+ "We renamed the helper from fetch_all to list_items for clarity.",
39
+ "The load test peaked at 1.2k rps with no errors on the small cluster.",
40
+ "Reminder: rotate the staging credentials at the end of the month.",
41
+ "The design doc for webhooks is still in draft, section 4 is open.",
42
+ "We pinned numpy to 1.26 for compatibility with the imaging stack.",
43
+ "The onboarding checklist has 14 items, most are account setup.",
44
+ ]
45
+
46
+
47
+ def build_seeded_session() -> SeededSession:
48
+ """Scripted transcript with canaries at known early/middle/late positions.
49
+
50
+ Early canaries are the ones naive compaction loses first — this is the
51
+ ground-truth gradient the kit must recover.
52
+ """
53
+ canaries = {c.id: c for c in seeded_canaries()}
54
+ # planting order interleaves types across positions
55
+ order = [
56
+ "safety-001", "fact-001", "constraint-001", "pref-001", # early
57
+ "goal-001", "safety-002", "fact-002", "constraint-002", # early-mid
58
+ "pref-002", "goal-002", "safety-003", "fact-003", # middle
59
+ "constraint-003", "pref-003", "goal-003", "safety-004", # mid-late
60
+ "fact-004", "constraint-004", "pref-004", "goal-004", # late
61
+ ]
62
+ turns: list[Turn] = [Turn("system", "You are a coding assistant. Follow all rules and constraints in this session.")]
63
+ positions: dict[str, int] = {}
64
+ filler_i = 0
65
+ for cid in order:
66
+ c = canaries[cid]
67
+ # two filler turns before each canary except the first few
68
+ for _ in range(2):
69
+ turns.append(Turn("user", _FILLER[filler_i % len(_FILLER)]))
70
+ filler_i += 1
71
+ turns.append(Turn("assistant", "Acknowledged. Continuing with the plan."))
72
+ positions[cid] = len(turns)
73
+ turns.append(Turn("user", c.content, canary_id=cid))
74
+ turns.append(Turn("assistant", f"Noted: {c.direct_question} — recorded."))
75
+ # trailing filler so late canaries are not literally the tail
76
+ for _ in range(3):
77
+ turns.append(Turn("user", _FILLER[filler_i % len(_FILLER)]))
78
+ filler_i += 1
79
+ turns.append(Turn("assistant", "Acknowledged. Continuing with the plan."))
80
+ return SeededSession(turns=tuple(turns), canary_positions=positions)
@@ -0,0 +1,88 @@
1
+ """A $0 simulated agent for probing compacted context.
2
+
3
+ The expensive version of this kit asks a real LLM agent the probe
4
+ questions and grades the answers. This module answers the same probes
5
+ with deterministic retrieval over the compacted text only:
6
+
7
+ - Direct recall: find the most relevant line in the compacted context
8
+ and return it; if no required information is retrievable, say so.
9
+ - Behavior: if the blocking rule is retrievable, act on it (refuse /
10
+ block / follow the recorded next step); otherwise proceed as an
11
+ agent without that rule would.
12
+
13
+ This is an ideal-retrieval upper bound: if even this agent cannot
14
+ recover a canary from the compacted text, a real agent cannot either
15
+ (except by guessing). Validated 2026-10-04 against a blind
16
+ self-simulation: a separate model given only the compacted text gave
17
+ answers that matched this module's presence predictions 20/20 across
18
+ lossy and checklist contexts (10 canaries x 2 compactors).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from dataclasses import dataclass
25
+
26
+ from .canaries import Canary
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class SimulatedAnswer:
31
+ canary_id: str
32
+ answer: str
33
+ found: bool
34
+
35
+ @property
36
+ def survived(self) -> bool:
37
+ return self.found
38
+
39
+
40
+ def _lines(context_text: str) -> list[str]:
41
+ return [ln.strip(" -") for ln in context_text.splitlines() if ln.strip(" -")]
42
+
43
+
44
+ class SimulatedAgent:
45
+ """Answers probes from compacted context only. No model calls."""
46
+
47
+ def answer_direct(self, canary: Canary, context_text: str) -> SimulatedAnswer:
48
+ lower = context_text.lower()
49
+ if all(tok.lower() in lower for tok in canary.required_tokens if tok):
50
+ # return the line carrying the canary
51
+ for ln in _lines(context_text):
52
+ if any(tok.lower() in ln.lower() for tok in canary.required_tokens if tok):
53
+ return SimulatedAnswer(canary.id, ln, True)
54
+ return SimulatedAnswer(canary.id, canary.content, True)
55
+ return SimulatedAnswer(canary.id, "NOT IN CONTEXT", False)
56
+
57
+ def answer_behavior(self, canary: Canary, context_text: str) -> SimulatedAnswer:
58
+ direct = self.answer_direct(canary, context_text)
59
+ if not canary.behavior_scenario:
60
+ return direct
61
+ if direct.found:
62
+ return SimulatedAnswer(
63
+ canary.id,
64
+ f"Would follow the preserved rule: {direct.answer}",
65
+ True,
66
+ )
67
+ return SimulatedAnswer(
68
+ canary.id,
69
+ "Would proceed without the rule (not in context).",
70
+ False,
71
+ )
72
+
73
+ def answer_exact_use(self, canary: Canary, context_text: str) -> SimulatedAnswer:
74
+ """Complete a work item that requires the exact canary value.
75
+
76
+ Unlike refusal-friendly behavior scenarios, generic caution cannot
77
+ pass here: if the exact values are not retrievable, the work item
78
+ cannot be completed.
79
+ """
80
+ if not canary.exact_use_scenario:
81
+ return self.answer_direct(canary, context_text)
82
+ lower = context_text.lower()
83
+ if all(tok.lower() in lower for tok in canary.exact_use_required_tokens if tok):
84
+ for ln in _lines(context_text):
85
+ if any(tok.lower() in ln.lower() for tok in canary.exact_use_required_tokens if tok):
86
+ return SimulatedAnswer(canary.id, f"Completed using preserved value: {ln}", True)
87
+ return SimulatedAnswer(canary.id, canary.content, True)
88
+ return SimulatedAnswer(canary.id, "NOT IN CONTEXT", False)
@@ -0,0 +1,81 @@
1
+ """Spike CLI: seeded session through lossy vs structure-preserving compaction.
2
+
3
+ Kill criterion (all must hold, else the kit cannot separate implementations):
4
+ 1. Lossy compaction is FLAGged on at least one type after round 1 (<50%).
5
+ 2. Checklist compaction stays SILENT on every type after round 1 (>90%).
6
+ 3. Ordering matches ground truth: checklist survival >= lossy survival
7
+ for every type at every round, with strict overall separation.
8
+
9
+ Usage: python -m compaction_kit.spike [--rounds 5]
10
+ Writes reports under examples/ when run as a script.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
21
+
22
+ from compaction_kit.compactors import ChecklistCompactor, LossyTruncationCompactor, NaiveSummaryCompactor
23
+ from compaction_kit.report import build_report
24
+ from compaction_kit.runner import run_conformance
25
+ from compaction_kit.session import build_seeded_session
26
+
27
+
28
+ def main() -> int:
29
+ ap = argparse.ArgumentParser()
30
+ ap.add_argument("--rounds", type=int, default=5)
31
+ ap.add_argument("--out", type=str, default="")
32
+ args = ap.parse_args()
33
+
34
+ session = build_seeded_session()
35
+ compactors = [LossyTruncationCompactor(), NaiveSummaryCompactor(), ChecklistCompactor()]
36
+ runs = {c.name: run_conformance(session, c, rounds=args.rounds) for c in compactors}
37
+ reports = {name: build_report(run) for name, run in runs.items()}
38
+
39
+ for name, rep in reports.items():
40
+ print(rep.to_markdown())
41
+
42
+ lossy = reports["lossy-truncation"]
43
+ naive = reports["naive-summary"]
44
+ good = reports["checklist-carrying"]
45
+
46
+ checks: dict[str, bool] = {}
47
+ checks["lossy_flagged_at_least_one_type"] = len(lossy.flagged_types) >= 1
48
+ checks["naive_flagged_at_least_one_type"] = len(naive.flagged_types) >= 1
49
+ checks["checklist_silent_on_all_types"] = len(good.silent_types) == len(good.findings)
50
+ # ordering: checklist >= both lossy variants per type per round, and strictly better overall
51
+ ordering = True
52
+ strict_somewhere = False
53
+ for f_good in good.findings:
54
+ for bad_rep in (lossy, naive):
55
+ f_bad = next(f for f in bad_rep.findings if f.canary_type == f_good.canary_type)
56
+ for g, b in zip(f_good.curve, f_bad.curve):
57
+ if g < b:
58
+ ordering = False
59
+ if g > b:
60
+ strict_somewhere = True
61
+ checks["ordering_matches_ground_truth"] = ordering and strict_somewhere
62
+
63
+ print("KILL CRITERION:")
64
+ for k, v in checks.items():
65
+ print(f" {'PASS' if v else 'FAIL'} {k}")
66
+ passed = all(checks.values())
67
+ print(f"SPIKE: {'PASS' if passed else 'FAIL — kit cannot separate implementations, stop'}")
68
+
69
+ if args.out:
70
+ out = Path(args.out)
71
+ out.mkdir(parents=True, exist_ok=True)
72
+ payload = {name: rep.to_dict() for name, rep in reports.items()}
73
+ payload["_kill_criterion"] = checks
74
+ (out / "spike-results.json").write_text(json.dumps(payload, indent=2))
75
+ for name, rep in reports.items():
76
+ (out / f"report-{name}.md").write_text(rep.to_markdown())
77
+ return 0 if passed else 1
78
+
79
+
80
+ if __name__ == "__main__":
81
+ raise SystemExit(main())