Skip to content

Commit 8a4b630

Browse files
jeremymanningclaude
andcommitted
feat(020): deterministic empirical-value strip — GUARANTEED planning removal
The LLM extractor's recall is non-deterministic, so it alone cannot guarantee SC-001 (a run found 0 claims on the real plan). claims/planning_scan adds a DETERMINISTIC final pass to the planning branch that removes every high-confidence empirical value regardless of LLM recall: STRIPPED comma-grouped counts (27,635; 1,701,936; 2,000), percentages (≥95%, 10%), timed/measured quantities (15 minutes, 60s, 1s) PRESERVED structural numbers — scope bounds (≤13 crossings), indices (crossing number 13), ranges (1-10), versions (1.0.0), dates (2026-05-29), identifiers (SHA-256, ds002800), bare decimal thresholds (0.7); markdown headers + fenced code untouched. Runs AFTER the LLM strip/smooth (which handles prose quality on what it detected) as the guarantee net. Idempotent. process_document(stage=planning) now strips the empirical value even when extract_claims returns [] (proven in test_planning_scan). Real PROJ-552 plan end-to-end: the wrong "27,635 at crossing 13", "~2,000", "≥95%", "15 minutes" are removed while "crossing number 13"/"Phase 1"/dates/SHA survive — and it passes even on a run where the LLM extractor found 0 claims. Offline gate 2295 passed / 0 failed; planning_scan suite 18/0. ruff + mypy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1bdf160 commit 8a4b630

4 files changed

Lines changed: 235 additions & 10 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Spec 020 — deterministic empirical-value strip for planning documents.
2+
3+
The LLM extractor (``extract_claims``) raises planning recall but cannot GUARANTEE
4+
that every low-level empirical value is detected — it is non-deterministic, so a
5+
given run may miss some. This module is the deterministic GUARANTEE: it removes the
6+
high-confidence empirical patterns spec 020 defers out of planning docs, regardless
7+
of the LLM's recall, while being conservative enough never to touch STRUCTURAL
8+
numbers.
9+
10+
STRIPPED (high-confidence empirical specifics):
11+
- comma-grouped counts/magnitudes: 27,635 1,701,936 2,000
12+
- percentages: 95% ≥10% 0.5%
13+
- timed/measured quantities: 15 minutes 60s 1s 2 hours 30 ms
14+
15+
PRESERVED (structural / qualifier — never matched):
16+
- scope bounds & indices: ≤13 crossings Phase 1 crossing number 13
17+
- ranges: 1-10
18+
- versions / dotted: 1.0.0
19+
- dates: 2026-05-29
20+
- hashes / identifiers: SHA-256 ds002800
21+
- bare decimals (thresholds): 0.7 (ambiguous — left to the LLM pass)
22+
23+
It runs as the FINAL pass of the planning claim layer, AFTER the LLM strip/smooth,
24+
so the LLM handles prose quality on what it detected and this guarantees nothing
25+
empirical survives. Idempotent: once the tokens are gone, re-running is a no-op.
26+
PURE / deterministic — no IO, no LLM.
27+
"""
28+
29+
from __future__ import annotations
30+
31+
import re
32+
33+
# A comma-grouped number requires at least one ``,ddd`` group, so a bare index/year
34+
# (13, 1976) and a sentence comma after a number ("number 13, the …") never match.
35+
_COMMA_NUM = r"\d{1,3}(?:,\d{3})+(?:\.\d+)?"
36+
_PERCENT = r"\d+(?:\.\d+)?\s*%"
37+
# Explicit time/size units allow a space; the bare "s" form must be attached
38+
# (``60s``) so "13 strands" / "5 servers" are never matched.
39+
_TIMED = (
40+
r"\d+(?:\.\d+)?\s*"
41+
r"(?:milliseconds?|ms|seconds?|secs?|minutes?|mins?|hours?|hrs?|days?)\b"
42+
r"|\d+s\b"
43+
)
44+
45+
# The empirical token, NOT preceded by a word char / slash / dot / hyphen — so a
46+
# number inside an identifier or hyphenated/dotted token (SHA-256, 1.0.0, ds-002)
47+
# is never picked up.
48+
_EMPIRICAL_TOKEN = re.compile(
49+
rf"(?<![\w/.\-])(?:{_COMMA_NUM}|{_PERCENT}|{_TIMED})",
50+
re.IGNORECASE,
51+
)
52+
53+
# A leading comparison operator (≥ ~ < …) or approximator word to absorb together
54+
# with the value, so "≥95%" / "within 15 minutes" / "~27,635" leave clean prose.
55+
_LEAD = re.compile(
56+
r"(?:[~≈≥≤<>±]\s*|"
57+
r"\b(?:approximately|about|roughly|around|nearly|exactly|over|under|"
58+
r"up\s+to|at\s+least|at\s+most|within|a\s+total\s+of|some)\s+)$",
59+
re.IGNORECASE,
60+
)
61+
62+
63+
def has_empirical_value(text: str) -> bool:
64+
"""True iff ``text`` contains a high-confidence empirical value (count/%/time)."""
65+
return _EMPIRICAL_TOKEN.search(text) is not None
66+
67+
68+
def _tidy(line: str) -> str:
69+
line = re.sub(r"[ \t]{2,}", " ", line)
70+
line = re.sub(r"\(\s*\)", "", line) # empty parens left by removal
71+
line = re.sub(r"\(\s+", "(", line)
72+
line = re.sub(r"\s+([.,;:)])", r"\1", line)
73+
line = re.sub(r"([(])\s*[,;]\s*", r"\1", line)
74+
return line.rstrip()
75+
76+
77+
def strip_empirical_values(text: str) -> str:
78+
"""Remove every high-confidence empirical value from ``text`` (deterministic).
79+
80+
Operates line by line (markdown headers and fenced code are left untouched).
81+
For each empirical token it also absorbs an immediately-preceding comparison
82+
operator / approximator (``≥95%`` → removed whole). Guaranteed claim-free of
83+
these patterns and idempotent. Surrounding prose, references, and all
84+
structural numbers are preserved.
85+
"""
86+
out: list[str] = []
87+
in_fence = False
88+
for line in text.split("\n"):
89+
stripped = line.lstrip()
90+
if stripped.startswith("```") or stripped.startswith("~~~"):
91+
in_fence = not in_fence
92+
out.append(line)
93+
continue
94+
if in_fence or stripped.startswith("#"):
95+
out.append(line)
96+
continue
97+
new = line
98+
# Remove tokens right-to-left so earlier offsets stay valid.
99+
while True:
100+
m = _EMPIRICAL_TOKEN.search(new)
101+
if m is None:
102+
break
103+
start = m.start()
104+
lead = _LEAD.search(new[:start])
105+
if lead is not None:
106+
start = lead.start()
107+
new = new[:start] + new[m.end():]
108+
out.append(_tidy(new) if new != line else line)
109+
return "\n".join(out)
110+
111+
112+
__all__ = ["has_empirical_value", "strip_empirical_values"]

src/llmxive/claims/service.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from llmxive.claims.extract import extract_claims
2222
from llmxive.claims.gate import strip_claim_artifacts
2323
from llmxive.claims.models import Claim, ClaimKind, ClaimStatus
24+
from llmxive.claims.planning_scan import strip_empirical_values
2425
from llmxive.claims.pointer import (
2526
GateReport,
2627
drop_orphan_pointers,
@@ -216,6 +217,14 @@ def _process_planning_document(
216217
continue
217218
if replacement != span:
218219
smoothed = smoothed.replace(span, replacement, 1)
220+
# spec 020 GUARANTEE: the LLM extractor's recall is non-deterministic, so a
221+
# final DETERMINISTIC pass removes any high-confidence empirical value (a
222+
# comma-grouped count, a percentage, a timed quantity) it missed — structural
223+
# numbers (versions, dates, indices, scope bounds) are preserved. Idempotent.
224+
try:
225+
smoothed = strip_empirical_values(smoothed)
226+
except Exception as exc: # never block a planning render on the guarantee pass
227+
logger.warning("claims.service: deterministic empirical strip failed (%s)", exc)
219228
# No markers, no kickback, no resolution: planning never blocks on low-level.
220229
return smoothed, [], GateReport()
221230

tests/integration/test_proj552_real_plan.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -79,15 +79,17 @@ def test_real_proj552_plan_planning_stage_no_kickback(tmp_path: Path) -> None:
7979
# The real file on disk is untouched (read-only).
8080
assert _PLAN.read_text(encoding="utf-8") == original
8181

82-
# Value REMOVAL (SC-001) is BEST-EFFORT, not guaranteed per-run: the planning
83-
# extractor uses the PLANNING-RECALL addendum (verified deterministically in
84-
# tests/unit/test_planning_recall_prompt.py), which substantially raises recall
85-
# on planning scope/metadata (0 -> 6 empirical claims in the real plan). But
86-
# extract_claims is an LLM call and is NON-DETERMINISTIC, so a given run may
87-
# miss some/all values. Only a deterministic detector could GUARANTEE removal.
88-
# This test therefore asserts the RELIABLE guarantees (anti-stall above) + that
89-
# the doc is never over-stripped/mangled; whatever values ARE detected get
90-
# smoothed (the strip/smooth fallback guarantees the detected value is removed).
82+
# Value REMOVAL (SC-001) is now GUARANTEED: after the (non-deterministic) LLM
83+
# strip/smooth, a DETERMINISTIC pass (claims/planning_scan) removes every
84+
# high-confidence empirical value, so the wrong "27,635 at crossing number 13"
85+
# and the other counts/percentages/durations cannot survive — regardless of
86+
# what the LLM extractor recalled this run.
87+
assert "27,635" not in out and "27635" not in out, (
88+
f"the wrong low-level count survived the planning stage:\n{out[:1500]}"
89+
)
90+
assert "2,000" not in out and "95%" not in out, "an empirical value survived"
91+
# …while the document is NOT over-stripped/mangled: structure, the research
92+
# subject, and STRUCTURAL numbers (the crossing-number index, the project id) survive.
9193
assert "## " in out, "document headers were lost (over-stripping)"
92-
assert "crossing number" in out, "the research subject was over-stripped"
94+
assert "crossing number 13" in out, "the structural crossing-number index was over-stripped"
9395
assert len(out) > len(original) * 0.6, "document was massively truncated (over-strip)"

tests/unit/test_planning_scan.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""Spec 020 — deterministic empirical-value strip + the planning GUARANTEE (offline).
2+
3+
``planning_scan.strip_empirical_values`` removes high-confidence empirical values
4+
(comma-grouped counts, percentages, timed quantities) while preserving structural
5+
numbers. The integration test proves the GUARANTEE: even when the LLM extractor
6+
returns ZERO claims (its non-deterministic worst case), the planning branch still
7+
strips the empirical value via this deterministic pass.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from pathlib import Path
13+
from typing import Any
14+
15+
import pytest
16+
17+
from llmxive.claims import service as svc
18+
from llmxive.claims.gate import CLAIM_MARKER_PREFIX
19+
from llmxive.claims.planning_scan import has_empirical_value, strip_empirical_values
20+
21+
22+
@pytest.mark.parametrize(
23+
"text,gone",
24+
[
25+
("There are 27,635 prime knots.", "27,635"),
26+
("~2,000 prime knots downloaded.", "2,000"),
27+
("≥95% data completeness.", "95%"),
28+
("reference coverage ≥10%.", "10%"),
29+
("Complete within 15 minutes.", "15 minutes"),
30+
("Exponential backoff (1s → 60s max).", "60s"),
31+
("The first 1,701,936 knots.", "1,701,936"),
32+
],
33+
)
34+
def test_strips_empirical(text: str, gone: str) -> None:
35+
out = strip_empirical_values(text)
36+
assert gone not in out
37+
assert not has_empirical_value(out)
38+
39+
40+
@pytest.mark.parametrize(
41+
"text,kept",
42+
[
43+
("prime knots at ≤13 crossings", "13"), # scope bound
44+
("crossing number 13", "13"), # index
45+
("crossing numbers 1-10", "1-10"), # range
46+
("Phase 1 analysis", "Phase 1"), # phase
47+
("checksummed via SHA-256", "SHA-256"), # identifier
48+
("version 1.0.0 ratified", "1.0.0"), # version
49+
("on 2026-05-29", "2026-05-29"), # date
50+
("citation title overlap ≥0.7", "0.7"), # bare decimal threshold
51+
("dataset ds002800", "ds002800"), # dataset id
52+
],
53+
)
54+
def test_preserves_structural(text: str, kept: str) -> None:
55+
assert kept in strip_empirical_values(text)
56+
57+
58+
def test_idempotent_and_headers_and_fences() -> None:
59+
doc = (
60+
"# Heading with 27,635 in it\n"
61+
"There are 27,635 knots, ≥95% complete.\n"
62+
"```\ncode with 1,234 stays\n```\n"
63+
)
64+
once = strip_empirical_values(doc)
65+
# header line + fenced code are untouched
66+
assert "# Heading with 27,635 in it" in once
67+
assert "code with 1,234 stays" in once
68+
# the prose value is gone
69+
assert "There are 27,635 knots" not in once
70+
assert strip_empirical_values(once) == once # idempotent
71+
72+
73+
def _lowlevel_claim_doc() -> str:
74+
return (
75+
"# Plan\n\n## Scale/Scope\n\n"
76+
"~2,000 prime knots (1-10), ~27,635 at crossing number 13 (Phase 1).\n"
77+
)
78+
79+
80+
def test_planning_branch_guarantees_strip_even_when_extractor_finds_nothing(
81+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
82+
) -> None:
83+
# Simulate the LLM extractor's worst case: it returns ZERO claims. The
84+
# deterministic pass must STILL strip the empirical values (the guarantee).
85+
monkeypatch.setattr(svc, "extract_claims", lambda *a, **k: [])
86+
87+
def _boom(*a: Any, **k: Any) -> Any:
88+
raise AssertionError("resolve() called in a planning stage")
89+
90+
monkeypatch.setattr(svc, "resolve", _boom)
91+
92+
out, claims, report = svc.process_document(
93+
_lowlevel_claim_doc(),
94+
artifact_path="projects/PROJ-x/specs/plan.md",
95+
project_id="PROJ-x", backend=object(), model=None,
96+
repo_root=tmp_path, stage_label="plan",
97+
)
98+
assert "27,635" not in out and "2,000" not in out # stripped (guaranteed)
99+
assert "crossing number 13" in out and "Phase 1" in out # structural preserved
100+
assert CLAIM_MARKER_PREFIX not in out # still no kickback
101+
assert report.blocked is False
102+
assert claims == []

0 commit comments

Comments
 (0)