custos-code 0.0.1__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.
custos_code/claims.py ADDED
@@ -0,0 +1,319 @@
1
+ """Report -> atomic claims (and request/plan -> requirements).
2
+
3
+ Owns: sentence splitting, the regex baseline (`extract_regex`), and the extractor entry point
4
+ (`extract`) that will route to an LLM backend once one is configured. Drops opinions, plans,
5
+ questions, and statements about what someone else did.
6
+ Must never: invent a claim that is not a verbatim span of the report.
7
+
8
+ Design notes, from a survey of 102 real final reports (1,419 sentences) on this machine:
9
+ - keyword hits are mostly NOT claims ("I'll run…", "want rows created?", "the 39 spec tests are
10
+ waiting"), so the baseline favours precision: a claim needs a past-tense action verb that the
11
+ agent itself performed, or a test/build outcome statement, and it must not be future, conditional,
12
+ a question, or attributed to someone else ("updated by a sync that ran…", "already updated").
13
+ - objects (paths, commands, SHAs, refs, counts) are pulled from the same sentence only.
14
+
15
+ Measured on the gold set: extraction recall target >= 0.85 (EVIDENCE_PLAN §2). E6 decides
16
+ whether the LLM path replaces or supplements this for mechanical types.
17
+
18
+ Owner: Oliver.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from collections.abc import Iterable
24
+ from typing import Literal
25
+
26
+ from .models import Claim, ClaimType
27
+
28
+ # ---------- sentence splitting ----------
29
+ _SPLIT_RE = re.compile(r"(?<=[.!?])\s+(?=[A-Z`*\-(\[])|\n+")
30
+ _BULLET_RE = re.compile(r"^\s*(?:[-*•]|\d+[.)])\s+")
31
+ _MD_NOISE_RE = re.compile(r"\*{1,3}|__|(?<![\w/.])_|_(?![\w/.])|`{3}[a-z]*") # emphasis only; keeps snake_case
32
+
33
+
34
+ _ACTION_VERBS = (
35
+ r"(?:ran|re-?ran|executed|added|created|wrote|edited|updated|changed|modified|patched|rewrote|"
36
+ r"refactored|fixed|implemented|wired|replaced|removed|deleted|committed|pushed|merged|deployed|"
37
+ r"verified|validated|confirmed|checked|reviewed|tested)"
38
+ )
39
+ _CLAUSE_RE = re.compile(
40
+ rf",?\s+and\s+(?=(?:I\s+|then\s+)?{_ACTION_VERBS}\b)"
41
+ r"|;\s+"
42
+ r"|,\s+(?=(?:all|every|lint|linting|ruff|mypy|tsc|build|tests?|the\s+suite|\d+\s+(?:passed|passing|tests?))\b)",
43
+ re.IGNORECASE,
44
+ )
45
+
46
+
47
+ def sentences(report: str) -> list[tuple[int, str]]:
48
+ """Split a report into (offset, clause) pairs. Boundaries: sentence ends, lines, bullets, and
49
+ coordinated action clauses (", and ran…", "; …", ", all 12 passing"). Offsets refer to the
50
+ markdown-stripped text, which is what claim texts are verbatim spans of."""
51
+ clean = _MD_NOISE_RE.sub("", report)
52
+ out: list[tuple[int, str]] = []
53
+ pos = 0
54
+ for raw in _SPLIT_RE.split(clean):
55
+ if raw is None:
56
+ continue
57
+ idx = clean.find(raw, pos)
58
+ if idx < 0:
59
+ idx = pos
60
+ pos = idx + len(raw)
61
+ base = _BULLET_RE.sub("", raw)
62
+ lead = len(raw) - len(base)
63
+ cpos = 0
64
+ for clause in _CLAUSE_RE.split(base):
65
+ if clause is None:
66
+ continue
67
+ cidx = base.find(clause, cpos)
68
+ cpos = (cidx if cidx >= 0 else cpos) + len(clause)
69
+ s = clause.strip().rstrip(",;")
70
+ if len(s) >= 8:
71
+ out.append((idx + lead + max(cidx, 0), s))
72
+ return out
73
+
74
+
75
+ # ---------- exclusions: not a claim about the agent's own completed action ----------
76
+ _NOT_CLAIM_RE = re.compile(
77
+ r"""
78
+ \?\s*$ # questions
79
+ | \b(?:I['’]ll|I\s+will|I\s+can|I\s+could|I\s+would|I['’]d|let\s+me|going\s+to|next\s+I|want\s+(?:me\s+)?to|should\s+I|shall\s+I|would\s+you|do\s+you\s+want|if\s+you|once\s+you|when\s+you|you\s+can|you\s+could|you\s+should|reply\s+with|say\s+the\s+word|tell\s+me)\b
80
+ | \b(?:already|previously|earlier\s+session)\b # not this session's work
81
+ | \bby\s+(?:a|an|the)\s+\w+\s+(?:that|which)\s+ran\b # "updated by a sync that ran…"
82
+ | \b(?:needs?|need\s+to|to\s+do|todo|pending|blocked|waiting|not\s+yet|still\s+(?:needs?|open|to))\b
83
+ | \b(?:cannot|can['’]t)\s+(?:assert|confirm|verify)\b
84
+ | ^(?:Debug|Fix|Add|Implement|Build|Update|Create|Run|Make|Write|Refactor|Investigate|Check|Review)\s+(?:the|a|an|this|these|your|my)\b # a task title, not a report of work done
85
+ | \b(?:are|is|were|was)\s+(?:all\s+)?showing\s+as\b # observed state, not an action the agent took
86
+ """,
87
+ re.IGNORECASE | re.VERBOSE,
88
+ )
89
+
90
+ # ---------- object extraction ----------
91
+ # Absolute and dot-prefixed paths must match whole, or a truncated path turns into a false
92
+ # accusation: `/Users/me/.claude/x/SKILL.md` once matched as `claude/x/SKILL.md`, which does not
93
+ # exist, which became `contradicted`. Leading `/`, `~/` and `.` are part of the path.
94
+ _PATH_RE = re.compile(
95
+ r"(?<![\w/~.-])((?:~|\.{1,2})?/?(?:[\w.-]+/)*[\w.-]+"
96
+ r"\.(?:py|ts|tsx|js|jsx|go|rs|java|kt|rb|php|c|cc|cpp|h|hpp|cs|swift|md|json|ya?ml|toml|cfg|ini|txt|sql|sh|css|html|env))\b"
97
+ )
98
+ _CMD_RE = re.compile(r"`([^`\n]{2,120})`")
99
+ _SHA_RE = re.compile(r"\b([0-9a-f]{7,40})\b")
100
+ _REF_RE = re.compile(r"\b(?:to|on|onto|into)\s+`?((?:origin/)?[\w./-]+)`?")
101
+ _COUNT_RE = re.compile(r"\b(\d+)\s*(?:/\s*(\d+))?\s*(?:tests?|specs?|cases?|checks?|passed|passing|green)\b")
102
+
103
+ # ---------- claim patterns, in priority order (first match wins per sentence) ----------
104
+ _PATTERNS: list[tuple[ClaimType, re.Pattern[str]]] = [
105
+ (ClaimType.DID_NOT_TOUCH, re.compile(r"\b(?:did\s+not|didn['’]t|never|no\s+longer|without)\s+(?:touch|modify|modif(?:y|ied)|change|changing|edit|editing|alter)\w*\b", re.I)),
106
+ (ClaimType.RUN_TESTS, re.compile(r"""
107
+ \b(?:all|every|full|entire|\d+(?:/\d+)?|the)\s+(?:tests?|specs?|checks?|suite)\b[^.]{0,40}\b(?:pass(?:ed|es|ing)?|green|succeed(?:ed|s)?)\b
108
+ | \b(?:tests?|suite|specs?)\s+(?:are\s+|is\s+|all\s+|now\s+)?(?:pass(?:ing|ed|es)?|green)\b
109
+ | \b\d+\s+(?:passed|passing)\b
110
+ | \b(?:ran|re-?ran|run|executed|running)\s+`?(?:the\s+)?(?:full\s+|entire\s+|whole\s+)?(?:test\s*suite|tests?|pytest|jest|vitest|cargo\s+test|go\s+test|npm\s+test|specs?)\b
111
+ | \bsuite\s+(?:is\s+)?(?:green|clean|passing)\b
112
+ """, re.I | re.X)),
113
+ (ClaimType.BUILD, re.compile(r"""
114
+ \b(?:build|builds|built|compil(?:es|ed|ation))\s+(?:is\s+|are\s+)?(?:clean|green|succeed(?:s|ed)?|successful(?:ly)?|pass(?:es|ed|ing)?|fine|ok)\b
115
+ | \b(?:lint(?:er|ing)?|ruff|eslint|mypy|tsc|typecheck(?:ing)?|type\s+checks?|pyright|clippy|black|prettier)\s+(?:is\s+|are\s+|now\s+)?(?:clean|green|pass(?:es|ed|ing)?|happy|reports?\s+no|has\s+no|no\s+(?:issues|errors|warnings))\b
116
+ | \bno\s+(?:lint|type|mypy|ruff)\s+(?:errors|issues|warnings)\b
117
+ """, re.I | re.X)),
118
+ (ClaimType.COMMIT, re.compile(r"\b(?:committed|pushed|merged|opened\s+(?:a\s+)?(?:pr|pull\s+request)|tagged|released\s+(?:v\d|version|\d|a\s+release|to\s+))", re.I)),
119
+ (ClaimType.DEPLOY, re.compile(r"\b(?:deployed|shipped\s+to|published\s+to|rolled\s+out)\b", re.I)),
120
+ (ClaimType.REVIEW_ALL, re.compile(r"\b(?:reviewed|read|inspected|audited|went\s+through|checked)\s+(?:all|every|each|the\s+full|the\s+entire|\d+)\s+(?:\w+\s+){0,2}(?:files?|modules?|functions?|tests?|lines?)\b", re.I)),
121
+ (ClaimType.DELETE, re.compile(r"\b(?:deleted|removed|dropped)\b", re.I)),
122
+ (ClaimType.CREATE, re.compile(r"\b(?:created|added|wrote|generated|scaffolded|introduced)\b", re.I)),
123
+ (ClaimType.EDIT, re.compile(r"\b(?:edited|updated|changed|modified|patched|rewrote|refactored|renamed|(?<!:\s)(?<!position\s)fixed|implemented|wired|replaced|moved|bumped|adjusted|tweaked)\b", re.I)),
124
+ (ClaimType.VERIFY, re.compile(r"\b(?:verified|validated|confirmed|double-?checked|checked|tested\s+(?:manually|by\s+hand|end-to-end|e2e|in\s+the\s+browser)|smoke-?tested|sanity-?checked)\b", re.I)),
125
+ (ClaimType.OBSERVED_OUTPUT, re.compile(r"\b(?:returned|returns|responded\s+with|output(?:s|ted)?\s+(?:is|was|shows?)|result\s+(?:is|was)\s+now|now\s+(?:prints|shows|returns)|exit(?:ed)?\s+(?:with\s+)?(?:code\s+)?\d)\b", re.I)),
126
+ (ClaimType.RUN_CMD, re.compile(r"\b(?:ran|re-?ran|executed|invoked)\s+`", re.I)),
127
+ ]
128
+
129
+ _VERIFY_MANUAL_RE = re.compile(r"\b(?:manually|by\s+hand|in\s+the\s+browser|visually|end-to-end|e2e|live)\b", re.I)
130
+
131
+
132
+ def _objects(sentence: str, ctype: ClaimType) -> list[str]:
133
+ objs: list[str] = []
134
+ objs += _PATH_RE.findall(sentence)
135
+ objs += [c.strip() for c in _CMD_RE.findall(sentence) if "/" in c or " " in c or "." in c]
136
+ if ctype == ClaimType.COMMIT:
137
+ objs += [s for s in _SHA_RE.findall(sentence) if not s.isdigit()]
138
+ objs += [r for r in _REF_RE.findall(sentence) if r.lower() not in {"the", "a", "it", "them", "this"} and not r.endswith(".py")]
139
+ if ctype == ClaimType.RUN_TESTS:
140
+ for n, total in _COUNT_RE.findall(sentence):
141
+ objs.append(f"{n}/{total}" if total else n)
142
+ if ctype == ClaimType.VERIFY and _VERIFY_MANUAL_RE.search(sentence):
143
+ objs.append("manual")
144
+ seen: set[str] = set()
145
+ out: list[str] = []
146
+ for o in objs:
147
+ o = o.strip().rstrip(".,;:)")
148
+ if o and o not in seen:
149
+ seen.add(o)
150
+ out.append(o)
151
+ return out
152
+
153
+
154
+ def classify(sentence: str) -> ClaimType | None:
155
+ """Return the claim type of a sentence, or None if it is not a claim about completed work."""
156
+ if _NOT_CLAIM_RE.search(sentence):
157
+ return None
158
+ for ctype, pat in _PATTERNS:
159
+ if pat.search(sentence):
160
+ return ctype
161
+ return None
162
+
163
+
164
+ def extract_regex(report: str, session_id: str) -> list[Claim]:
165
+ """The dumb baseline. Always shipped beside the LLM path (AGENTS.md invariant 8).
166
+
167
+ Precision over recall: a clause becomes at most one claim, typed by the first matching
168
+ pattern, with objects drawn from that clause only.
169
+ """
170
+ claims: list[Claim] = []
171
+ for _, sent in sentences(report):
172
+ ctype = classify(sent)
173
+ if ctype is None:
174
+ continue
175
+ polarity: Literal["did", "did_not"] = "did_not" if ctype == ClaimType.DID_NOT_TOUCH else "did"
176
+ claims.append(Claim(
177
+ id=f"c{len(claims) + 1}", session_id=session_id, text=sent, type=ctype,
178
+ objects=_objects(sent, ctype), polarity=polarity, source="report",
179
+ ))
180
+ return claims
181
+
182
+
183
+ CLASSIFY_SYSTEM = """You are given numbered sentences from an AI coding agent's final report to its user.
184
+
185
+ For each, decide: is it a CLAIM that the agent performed a specific action this session, or that
186
+ something is in a state because of what it did, such that evidence could exist in a log of its tool
187
+ calls, the filesystem, or git?
188
+
189
+ YES even if it is terse ("Now derives from --strip"), phrased as a result ("lint is clean"), or
190
+ negative ("I did not touch the tests").
191
+
192
+ NO for: markdown headings and labels, even ones that look like results ("## FIXED — nav overlapped
193
+ the headline"); explanations of how code or a system works; quoted output, code, diffs, or
194
+ measurements; plans and intentions; offers and questions; opinions and recommendations; work done by
195
+ someone else or in an earlier session; restatements of the user's request.
196
+
197
+ Judge each sentence independently and do not skip any. When YES, give the action type and the file
198
+ paths, commands, SHAs, refs or counts named in that sentence, copied verbatim."""
199
+
200
+ CLASSIFY_SCHEMA: dict[str, object] = {
201
+ "type": "object",
202
+ "additionalProperties": False,
203
+ "required": ["labels"],
204
+ "properties": {
205
+ "labels": {
206
+ "type": "array",
207
+ "items": {
208
+ "type": "object",
209
+ "additionalProperties": False,
210
+ "required": ["n", "is_claim", "type", "objects", "polarity"],
211
+ "properties": {
212
+ "n": {"type": "integer"},
213
+ "is_claim": {"type": "boolean"},
214
+ "type": {"type": "string", "enum": [t.value for t in ClaimType]},
215
+ "objects": {"type": "array", "items": {"type": "string"}},
216
+ "polarity": {"type": "string", "enum": ["did", "did_not"]},
217
+ },
218
+ },
219
+ }
220
+ },
221
+ }
222
+
223
+
224
+ def extract_llm(report: str, session_id: str, backend: object) -> list[Claim]:
225
+ """Per-sentence classification, not whole-report extraction (E6).
226
+
227
+ Measured on the 10 gold sessions (204 sentences, 63 labelled claims):
228
+
229
+ regex baseline recall 0.14 precision 0.60 F1 0.23
230
+ whole-report extraction recall 0.32 precision 0.87 F1 0.47
231
+ per-sentence, gpt-5-mini recall 0.95 precision 0.57 F1 0.71
232
+ per-sentence, gpt-5.2 recall 0.68 precision 0.83 F1 0.75
233
+
234
+ Classification wins for two reasons beyond the numbers: the model judges each sentence on its
235
+ own instead of deciding how much of a long report to emit, and verbatim spans are guaranteed by
236
+ construction because the model returns an index, never text. It cannot paraphrase a claim into
237
+ existence.
238
+ """
239
+ import json as _json
240
+
241
+ sents = sentences(report)
242
+ if not sents:
243
+ return []
244
+ client = backend.client() # type: ignore[attr-defined]
245
+ model = str(getattr(backend, "extractor_model", None) or getattr(backend, "judge_model", "") or "")
246
+ numbered = "\n".join(f"{i}. {t[:400]}" for i, (_, t) in enumerate(sents))
247
+
248
+ if hasattr(client, "responses"):
249
+ resp = client.responses.create(
250
+ model=model, instructions=CLASSIFY_SYSTEM, input=numbered,
251
+ text={"format": {"type": "json_schema", "name": "labels", "schema": CLASSIFY_SCHEMA, "strict": True}},
252
+ )
253
+ raw = _json.loads(resp.output_text)
254
+ usage = getattr(resp, "usage", None)
255
+ if usage is not None and hasattr(backend, "usage"):
256
+ from .judge import Usage
257
+ cached = getattr(getattr(usage, "input_tokens_details", None), "cached_tokens", 0) or 0
258
+ backend.usage.add(Usage(1, getattr(usage, "input_tokens", 0) or 0, cached,
259
+ getattr(usage, "output_tokens", 0) or 0, model))
260
+ else: # anthropic
261
+ resp = client.messages.create(
262
+ model=model, max_tokens=8000, system=CLASSIFY_SYSTEM,
263
+ messages=[{"role": "user", "content": f"{numbered}\n\nReply with JSON matching:\n{_json.dumps(CLASSIFY_SCHEMA)}"}],
264
+ )
265
+ text = "".join(b.text for b in resp.content if getattr(b, "type", "") == "text")
266
+ a, b_ = text.find("{"), text.rfind("}")
267
+ raw = _json.loads(text[a:b_ + 1]) if a >= 0 else {"labels": []}
268
+
269
+ out: list[Claim] = []
270
+ for item in raw.get("labels", []):
271
+ if not item.get("is_claim"):
272
+ continue
273
+ i = int(item.get("n", -1))
274
+ if not 0 <= i < len(sents):
275
+ continue
276
+ try:
277
+ ctype = ClaimType(item.get("type", "other"))
278
+ except ValueError:
279
+ ctype = ClaimType.OTHER
280
+ polarity: Literal["did", "did_not"] = "did_not" if item.get("polarity") == "did_not" else "did"
281
+ objs = [str(o) for o in item.get("objects", []) if str(o).strip()]
282
+ out.append(Claim(id=f"l{len(out) + 1}", session_id=session_id, text=sents[i][1], type=ctype,
283
+ objects=objs or _objects(sents[i][1], ctype), polarity=polarity, source="report"))
284
+ return out
285
+
286
+
287
+ def extract(report: str, session_id: str, backend: object | None = None) -> list[Claim]:
288
+ """Extractor entry point (E6).
289
+
290
+ Decision (measured 2026-09-19; numbers in `extract_llm`): **the sentence classifier leads and
291
+ the regex is the fallback**, not the other way round. The regex recovers 0.14 of real claims:
292
+ an agent phrases a claim however it likes, so no pattern list closes that gap. What the regex
293
+ is still good for is running with no API key and no network, and that is why it stays.
294
+
295
+ With a backend, both run and the union is returned. A claim the regex finds is free and a claim
296
+ only the classifier finds is worth its cost; over-extraction is survivable here because a junk
297
+ claim gets an `unwitnessed` verdict rather than an accusation, while a missed claim is never
298
+ checked at all. A backend failure degrades to the baseline rather than to nothing.
299
+ """
300
+ base = extract_regex(report, session_id)
301
+ if backend is None:
302
+ return base
303
+ try:
304
+ llm = extract_llm(report, session_id, backend)
305
+ except Exception: # a backend failure must never lose the deterministic claims
306
+ return base
307
+ return merge(base, llm)
308
+
309
+
310
+ def merge(a: Iterable[Claim], b: Iterable[Claim]) -> list[Claim]:
311
+ """Union two claim lists by (type, verbatim text); keeps first occurrence order."""
312
+ seen: set[tuple[ClaimType, str]] = set()
313
+ out: list[Claim] = []
314
+ for c in list(a) + list(b):
315
+ key = (c.type, c.text)
316
+ if key not in seen:
317
+ seen.add(key)
318
+ out.append(c)
319
+ return out