Ran the humanizer rules over 79 prose files in the active specs (brief, design, requirements, research and narrative docs). Removed em dashes, significance inflation, forced rule-of-three, bold-label list items and filler. Skipped tasks.md checklists and the retired specs (harnessx-*, openrouter-sast-harness, unseen-repo-evaluation). Every number, path, command, hash and identifier was preserved: checked each file against HEAD for drift in numbers, inline code and code fences, zero drift across all 79 files. Co-Authored-By: Claude Opus 4.8 <[email protected]>
15 KiB
Design Document: constrained-detector
Overview
The detector stops searching. A deterministic pass enumerates the candidate sites inside a labeled region from the semantic IR; the model answers one bounded, typed question per candidate with no tools and no choice of what to look at; the predicates that decide whether a finding counts are applied while it is being made, with one retry. The unconstrained tool hunter stays in place as the generalist and as the baseline this is measured against.
Two measurements shape every decision below and neither was a guess. 120 runs produced 120 distinct trajectories, so the variance is the loop's freedom rather than the model's judgement. And the candidate generator was measured before anything was built: the pattern ruleset caps recall at 12.1%, below the 64.9% the unconstrained loop already reaches, while the semantic IR caps it at 96.6% with a median of ten sites per labeled function.
Goals
- Two runs of the same detector on the same input make an identical sequence of model calls.
- The limit on what the detector can find is a published number, not an assumption.
- Real-project slices decide whether a change is kept; hand-written teaching code cannot justify one alone.
- The K-run and noise-floor machinery is re-measured and deleted if a constrained detector does not need it.
Non-Goals
- Prompt optimisation, reflective evolution, any optimiser. Deferred to a later spec, on this feature's evidence.
- Changing the taxonomy, the classifier, the scorer's outcome rules, the split, the verifier boundary or the gates.
- Adopting DSPy as a dependency. The interface shape is borrowed; the framework is not.
Boundary Commitments
This Spec Owns
learning/candidates.py, what a candidate is, how the IR enumerates one per family shape, and the ceiling report.learning/judgments.py, the typed answer, and the toolless call that produces it.learning/predicates.py, the checks that constrain a claim at generation time and score it afterwards, defined once.learning/slices.py, deciding versus development slices, the overfitting gap, and the cross-slice rejection rule.run_constrained_detectorinlearning/detectors.py, beside the existingrun_family_detector.ousast learning candidates, and a detector selector onscoreandbaseline.
Out of Boundary
learning/families.py,classify.py,split.py,verifiers.py,canaries.py,journal.py,acceptance.py,rounds.py,publish.pykeep their contracts.scoring.pyis touched only to import predicates it already implements inline, and a test asserts its behaviour is unchanged.tool_hunter.run_tool_hunterkeeps its contract and its callers. It is not removed while it scores higher on any deciding slice.- The pair corpus, its labels, tiers and splits. The three gates.
Allowed Dependencies
- Core: standard library only.
dependencies = []is unchanged. - Optional: the semantic extra for the IR. Without it, every family reports
unsupported_languageand scores nothing. - The chat endpoint already resolved by
learning/endpoint.py. No new provider, no new key.
Revalidation Triggers
- The candidate ceiling on a deciding slice falling below the unconstrained loop's detection rate on that slice.
- The re-measured trajectory count per pair exceeding one.
- Any change to
scoring.py's outcome rules, which would invalidate the baseline this is compared against.
Architecture
Existing Architecture Analysis
run_family_detector composes run_tool_hunter with a family's prompt, checklist, curated tools and context files.
The hunter loop is a ReAct loop: the model calls tools until it stops, then emits findings, and a final turn asks for
JSON when it ended in prose. _overlay_scan already parses both sides of a pair and returns function ranges;
semantic/ir.py already exposes FunctionIR with calls: tuple[CallSite, ...] and binds: tuple[Bind, ...],
each carrying argument texts, the names they mention and whether they are constant. hunter_tools.obligations and
flows already answer the guard and reachability questions deterministically. Nothing new has to be parsed.
Architecture Pattern & Boundary Map
flowchart TD
R[Region: path, function, admitted families] --> E[candidates.enumerate_candidates]
IR[(semantic IR: calls, binds)] --> E
OB[(obligations, flows, entry_points)] --> E
E -->|"Candidate[] sorted, bounded"| J[judgments.judge_candidates]
J -->|"one typed call per batch, no tools"| M[[chat endpoint]]
M --> P[predicates.check]
P -->|violation| J
P -->|clean| F[StaticFinding: family, detector, verifier tags]
F --> S[learning.scoring.score_pair_family]
P -.->|same definition| S
E --> C[CandidateReport: ceiling per family per slice]
The dotted edge is the point of Requirement 4: one definition of the predicates, used to constrain and to score.
Technology Stack
| Concern | Choice | Why |
|---|---|---|
| Candidate enumeration | semantic/ir.py FunctionIR.calls and .binds |
96.6% ceiling measured; the ruleset's 12.1% is below the current baseline |
| Guards in scope | hunter_tools.obligations, mapping.analyze_entry_points |
already deterministic, already used by the obligations checker |
| Typed answer | json_object on the DeepSeek adapter, one array per batch |
the provider has no schema mode; the predicates are the schema |
| Retry on violation | one bounded retry with the violation injected | DSPy Assertions' backtracking, without the framework |
| Determinism | candidates sorted, batches fixed, no tools | Req 2.4; the model's text may vary, the call sequence may not |
File Structure Plan
Directory Structure
src/openultrasast/learning/
├── candidates.py # NEW Candidate, CandidateKind, enumerate_candidates, ceiling, CandidateReport
├── judgments.py # NEW Judgment, judge_candidates, batching, the typed prompt
├── predicates.py # NEW PREDICATES, check_claim, violation reasons
├── slices.py # NEW DECIDING_SLICES, DEVELOPMENT_SLICES, overfitting_gap, cross_slice_verdict
├── detectors.py # MOD run_constrained_detector beside run_family_detector
└── scoring.py # MOD imports predicates; outcome rules unchanged, asserted by the recorded baseline
Modified Files
src/openultrasast/learning/detectors.py,run_constrained_detector(root, region, config, *, client, model, taxonomy, ranges);FamilyConfiggains nothing, because a constrained detector is configured by the same prompt/checklist/limits and reads no tools.src/openultrasast/learning/scoring.py,_hits,_othersand_fabricateddelegate topredicates; the family metrics gain nothing. A test asserts the recorded baseline numbers are reproduced exactly.src/openultrasast/learning/publish.py, the overfitting gap and the per-slice table (Req 3.1, 3.4).src/openultrasast/config.py,[learning] detector = "hunter" | "constrained", defaulthunteruntil the comparison says otherwise.src/openultrasast/cli.py,learning candidates;--detectoronscoreandbaseline.pyproject.toml, unchanged.
System Flows
One region, constrained
sequenceDiagram
participant D as run_constrained_detector
participant E as enumerate_candidates
participant J as judge_candidates
participant P as predicates
D->>E: root, region, family, ranges
E-->>D: Candidate[] (sorted by path, line, name; bounded)
alt no candidates
D-->>D: record no_candidate, return []
end
loop each fixed-size batch
D->>J: batch, config, client
J-->>D: Judgment[] (typed)
D->>P: judgment, candidate, ranges, taxonomy
alt violation
D->>J: retry once, violation injected
J-->>D: Judgment[]
D->>P: re-check
end
P-->>D: clean claims or a recorded discard
end
D-->>D: StaticFinding[] tagged family, detector, verifier
Requirements Traceability
| Criteria | Delivered by |
|---|---|
| 1.1, 1.2 | candidates.enumerate_candidates, CandidateKind per family shape |
| 1.3 | candidates.ceiling, ousast learning candidates, benchmarks/measurements/*-candidate-ceiling.json |
| 1.4 | enumerate_candidates returns Unsupported when parse_file(...).parse_ok is false |
| 1.5 | no_candidate joins the unscorable vocabulary in scoring.unscorable_reason |
| 1.6 | MAX_CANDIDATES_PER_CALL, CandidateReport.discarded |
| 2.1, 2.2, 2.3 | judgments.judge_candidates, Judgment, _typed_prompt; findings built from Judgment only |
| 2.4 | test_two_runs_make_an_identical_sequence_of_calls over a recording client |
| 2.5 | StaticFinding.tags gains candidate:<path>:<line>:<name> |
| 3.1, 3.4 | slices.overfitting_gap, publish._slice_table |
| 3.2, 3.3 | slices.DECIDING_SLICES, slices.cross_slice_verdict |
| 3.5 | learning candidates --slice over every slice; the artifact carries per-slice ceilings |
| 3.6 | slices.teachable(slice) and a note emitted with every published number |
| 3.7 | NON_GATING_FAMILIES unchanged; publish reports memory separately, never inside a web aggregate |
| 4.1, 4.2, 4.3 | predicates.PREDICATES, check_claim, one retry in run_constrained_detector, discarded tally |
| 4.4 | scoring._hits/_fabricated call predicates; the baseline-reproduction test pins no drift |
| 5.1, 5.2 | the trajectory script and run_baseline re-run against the constrained detector |
| 5.3, 5.4 | the published floor names the learning-harness requirements it would amend; K is labelled |
| 6.1 | score_pair_family unchanged; the constrained detector only produces findings |
| 6.2, 6.3 | the comparison artifact, per slice, against 64.9% / 12.3% / 52.6% and $0.030 / $0.136 |
| 6.4, 6.5 | the comparison's headline rule; [learning] detector keeps hunter selectable |
| 7.1 | resolve_chat_endpoint unchanged; learning_endpoint_unavailable recorded |
| 7.2 | the client's cost_usd meter, already wired |
| 7.3 | the committed gate baseline test |
| 7.4 | has_semantic_extra guard; unsupported_language without it |
| 7.5 | redact_secrets on every prompt and persisted trajectory |
Components and Interfaces
candidates.py
CandidateKind = Literal["call", "bind", "operation", "config"]
@dataclass(frozen=True)
class Candidate:
path: str
line: int
kind: CandidateKind
name: str # the callee, the bound name, or the operation
text: str # the site as written, clamped
arg_texts: tuple[str, ...]
arg_names: tuple[tuple[str, ...], ...]
binding_chain: tuple[str, ...] # what the IR resolved for the arguments, nearest first
guards: tuple[str, ...] # decorators and in-body guards in scope, from obligations
function: str
@property
def id(self) -> str: ... # f"{path}:{line}:{name}", the tag a finding carries
FAMILY_SHAPE: dict[str, tuple[CandidateKind, ...]] = {
"injection": ("call",), "path": ("call",), "deserialization": ("call",),
"untrusted_destination": ("call",), "prototype": ("call", "bind"),
"access_control": ("operation",), "config_secrets": ("bind", "config"),
"output_encoding": ("call",), "memory": ("call",), "unknown": ("call", "bind"),
}
MAX_CANDIDATES_PER_CALL = 12 # p90 of the measured distribution is 22; two batches covers all but the tail
def enumerate_candidates(root, region, family, *, taxonomy, limit=MAX_CANDIDATES_PER_CALL) -> CandidateSet
def ceiling(cases, *, taxonomy) -> CandidateReport # offline, no model
CandidateSet carries candidates, discarded, and reason ("", unsupported_language, no_candidate).
Ordering is (path, line, name), which makes the batches and therefore the call sequence deterministic.
judgments.py
@dataclass(frozen=True)
class Judgment:
candidate_id: str
verdict: bool
source: str # the expression carrying untrusted input, or ""
guard: str # the guard found, or "" meaning none
family: str
rationale: str
def judge_candidates(candidates, config, *, client, model, taxonomy, violation=None) -> tuple[Judgment, ...]
One call per batch, tools=[], json_object=True. The prompt is the family's prompt and checklist plus the batch
rendered as data; violation is the single retry's injected text. Answers that name a candidate id not in the batch
are dropped before the predicates run, because a fabricated id is not a claim about anything.
predicates.py
@dataclass(frozen=True)
class Violation:
predicate: str
detail: str
def check_claim(judgment, candidate, *, ranges, taxonomy) -> Violation | None
def inside_labeled_function(finding, spans) -> bool # used by scoring._hits
def is_known_family(tag, taxonomy) -> bool # used by scoring._fabricated
Named predicates: line_in_parsed_function, sink_at_line, family_in_taxonomy, source_in_function. The last
two of the three helpers are the ones scoring.py already implements inline; moving them here is a refactor whose
only observable effect must be none, which the recorded baseline pins.
slices.py
DECIDING_SLICES = ("agent-vfc", "vfc-js", "vfc", "github") # harvested from real projects
DEVELOPMENT_SLICES = ("vibe-py",) # hand-written; cannot justify a change alone
def overfitting_gap(per_slice: Mapping[str, FamilyMetrics]) -> float
def cross_slice_verdict(before, after) -> Verdict # rejects an improvement that regresses a deciding slice, by name
def teachable(slice_name: str, cases) -> bool # False while every row of a slice is below a gating tier
Error Handling
| Condition | Behaviour | Recorded as |
|---|---|---|
| IR cannot parse the side | no candidates, no model call | unsupported_language |
| Labeled function has no call or bind | no model call | no_candidate |
| Region exceeds the batch bound | judge the first limit, count the rest |
CandidateReport.discarded |
| Predicate fails twice | claim discarded | predicates.discarded[<name>], published |
| Model names a candidate id not in the batch | answer dropped before predicates | fabricated_candidate tally |
| Endpoint unavailable | score nothing | learning_endpoint_unavailable |
| Cost cap crossed | stop, keep what was measured | cost_cap_exceeded |
Migration and Phases
- Candidates and the ceiling, offline.
candidates.py,learning candidates, the per-slice ceiling artifact including the real-world slices (Req 3.5). No model call, so the phase is free and its result can still cancel the design. If the ceiling on a deciding slice is below that slice's current detection rate, stop here. - Predicates extracted.
predicates.py,scoring.pydelegating, the baseline-reproduction test. - Judgment and the constrained detector.
judgments.py,run_constrained_detector, the determinism test. - Slices and reporting.
slices.py, the overfitting gap,publishper slice. - The comparison. Constrained against unconstrained on every slice, the re-measured trajectory count and noise
floor, and the named
learning-harnessamendments if the floor collapses.
Phase 1 is deliberately able to kill the design before any money is spent on it.