File size: 25,985 Bytes
ab54eb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
"""
Realism-rejection feedback loop.

When the realism checker rejects a counterexample (verdict=UNREALISTIC),
the rejection contains useful information about WHY the witness was
unreachable. Instead of discarding it, the feedback loop distills the
rejection into one of three remediation classes:

  (a) CODE_CHANGE   — bmc-agent is missing a structural capability
                      (e.g., a new artifact-detector pattern, a parser
                      improvement, a harness-generator gap). Written to
                      ``TODO_BMC_AGENT.md`` for the developer to triage.

  (b) FUNCTION_SPEC — the spec for the function whose CE was rejected
                      was too permissive. A `__CPROVER_assume(...)` clause
                      is added to the function's spec/harness for the
                      NEXT sweep so CBMC never explores this state again.

  (c) PROJECT_INVARIANT — the rejection identifies a project-wide
                      invariant (e.g., ``xmlMalloc != NULL``). The clause
                      is persisted to ``<artifact_dir>/learned_constraints.json``
                      and applied to every harness in the project.

Persistence schema (learned_constraints.json)::

    {
      "version": 1,
      "function_clauses": {
        "<function_name>": ["clause1", "clause2", ...]
      },
      "project_clauses": ["xmlMalloc != NULL", ...],
      "code_change_todos": [
        {"description": "...", "from_function": "...", "from_property": "..."}
      ]
    }

All clauses are valid CBMC `__CPROVER_assume()` expressions and are
review-gated by default — see the soundness notes below.

Soundness
=========
A spec clause can hide real bugs if it's too strong. The loop mitigates
this in three ways:

  1. The LLM is prompted to emit ONLY invariants it can defend as
     "true in all real executions of any public API call into this code".
     If it can't, it must return `scope=code-change` instead, deferring
     to the developer.

  2. Each persisted clause carries a `confidence` and `rationale`. Low
     confidence clauses are written but disabled by default (require
     explicit opt-in to apply).

  3. The optional ``--feedback-soundness-gate`` flag re-runs CBMC after
     applying a new clause; if the function flips from FAIL to PASS,
     the clause is too strong and is rejected.
"""

from __future__ import annotations

import json
import os
import re
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Optional

from bmc_agent.logger import get_logger

if TYPE_CHECKING:
    from bmc_agent.cbmc import Counterexample
    from bmc_agent.config import Config
    from bmc_agent.llm import LLMClient
    from bmc_agent.parser import FunctionInfo, ParsedCFile
    from bmc_agent.realism_checker import RealismCheckResult

logger = get_logger("feedback_loop")


# ---------------------------------------------------------------------------
# Remediation types
# ---------------------------------------------------------------------------


class RemediationScope(str, Enum):
    CODE_CHANGE = "code-change"
    FUNCTION_SPEC = "function-spec"
    PROJECT_INVARIANT = "project-invariant"
    # Triggered when CBMC reports the FUT's POST assertion violated by
    # a path that real callers can also hit (i.e., the LLM-emitted POST
    # is over-tight, not the implementation being buggy). Drops the
    # offending POST clause from the FUT's spec on the next harness
    # emission. Orthogonal to caller-grounded spec gen.
    FUNCTION_POST_RELAX = "function-post-relax"
    NONE = "none"


@dataclass
class Remediation:
    """A proposed fix for a realism-rejected counterexample."""

    scope: RemediationScope
    clause: str = ""               # __CPROVER_assume content (no `assume(...)` wrapper)
    code_change: str = ""          # human-readable description for the developer
    rationale: str = ""            # why this fix is sound / appropriate
    confidence: str = "low"        # "high" | "medium" | "low"

    def to_dict(self) -> dict:
        return {
            "scope": self.scope.value,
            "clause": self.clause,
            "code_change": self.code_change,
            "rationale": self.rationale,
            "confidence": self.confidence,
        }


# ---------------------------------------------------------------------------
# Stub-precondition assertion detection
# ---------------------------------------------------------------------------

# ---------------------------------------------------------------------------
# LLM prompt for distillation
# ---------------------------------------------------------------------------


_DISTILL_PROMPT = """\
A formal verifier (CBMC) reported a property violation in a C function.
A subsequent realism check returned verdict={verdict}.

When the verdict is **UNREALISTIC**, the rejection contains structured
information about WHY the witness state cannot occur in real execution.

When the verdict is **UNCERTAIN**, the LLM couldn't commit to either
REALISTIC or UNREALISTIC — usually because something prevented analysis
(source truncation, missing callee body, ambiguous witness state, etc.).
UNCERTAIN verdicts are STRONG signals that bmc-agent needs a structural
improvement (more context, richer harness modelling, better stub
contract). Prefer scope="code-change" for UNCERTAIN unless you can
identify a concrete, defensible invariant.

Your task: pick ONE of three remediation paths and emit the change that
would prevent this kind of rejection from re-appearing.

=== FUNCTION ===
{function_name}

=== FUNCTION BODY ===
{function_body}

=== VIOLATED PROPERTY ===
{violated_property}

=== COUNTEREXAMPLE WITNESS (variable assignments) ===
{witness_state}

=== REALISM CHECK ===
verdict: {verdict}
reasoning: {rejection_reasoning}
key_concern: {key_concern}

=== EXISTING PROJECT INVARIANTS (already learned) ===
{existing_project_clauses}

---

Four remediation paths — pick exactly ONE:

  (a) scope="code-change"
        bmc-agent itself is missing a structural capability that would
        prevent this witness pattern. Examples: a new
        witness-pattern artifact detector, a parser fix to recognize a
        typedef form, a harness-generator gap for some struct field
        type, an unmodeled stub return contract.
        REQUIRED: `code_change` field describes what to add to bmc-agent
        (one-paragraph) so the developer can implement it.

  (b) scope="function-spec"
        The spec for THIS function is too permissive — its precondition
        admits a state that no real caller can produce. The clause is
        a single C boolean expression true in all real calls to this
        function. Applied to the next harness for THIS function only.
        REQUIRED: `clause` field is a valid C boolean expression
        referring to the function's parameters and reachable globals.

  (c) scope="project-invariant"
        The same rejection would apply to MANY functions in this
        project — it's a global invariant of the library. Persisted
        and applied to every future harness in the project. Use
        SPARINGLY; over-strong project invariants can hide real bugs.
        REQUIRED: `clause` field is a global C boolean expression
        (no function-local variables; e.g. `xmlMalloc != NULL`).

  CLAUSE SYNTAX RULES (for scope b/c; violating these makes the harness
  fail CBMC parse and the clause is discarded):

    * Must be a valid C boolean expression that compiles inside
      ``__CPROVER_assume(<clause>);``.
    * NO pseudo-formal-logic: ``forall X in S: ...`` and ``exists ...``
      are NOT C. CBMC's actual quantifiers are ``__CPROVER_forall {{ T
      x; P(x) }}`` and ``__CPROVER_exists {{ T x; P(x) }}`` with brace
      blocks — use those, or rewrite as a conjunction of concrete
      atoms over named fields.
    * NO mathematical set notation (``∈``, ``∀``, ``∃``, ``∧``, ``∨``).
    * For invariants over linked structures (rb-trees, linked lists),
      prefer assuming the ROOT pointer's validity rather than
      universally quantifying over every node — the latter rarely
      typechecks as C.

  (d) scope="function-post-relax"
        ONLY applicable when the violated property is the FUT's own
        postcondition assertion (CBMC names it ``main.assertion.<N>``).
        The offending POST clause is over-tight: real callers can hit
        the witness state, but the LLM-emitted post excludes it (e.g.,
        ``result == 0 || result < 0`` when real semantics allow small
        positive returns from ``copy_from_user``). The fix is to drop
        that clause from the **FUT's** postcondition.
        REQUIRED: `clause` is the precise atom to drop from this
        function's postcondition (DSL form).

If you can't safely propose any of these, return scope="none".

Soundness requirement: the clause MUST be true in every real execution
of every public API call. If you have any doubt, prefer scope="code-change"
(deferring to the developer) over scope="function-spec" (per-function
clause) over scope="project-invariant" (project-wide, highest risk).

Respond with ONLY valid JSON:
{{
  "scope": "code-change" | "function-spec" | "project-invariant" | "function-post-relax" | "none",
  "clause": "<C boolean expression — for function-spec / project-invariant / function-post-relax>",
  "code_change": "<short description of the bmc-agent change — for code-change>",
  "rationale": "<one paragraph: why is this sound? What real invariant does it encode?>",
  "confidence": "high" | "medium" | "low"
}}
"""


# ---------------------------------------------------------------------------
# Main entrypoint
# ---------------------------------------------------------------------------


def learn_from_rejection(
    config: "Config",
    llm: "LLMClient",
    func: "FunctionInfo",
    counterexample: "Counterexample",
    realism: "RealismCheckResult",
    existing_project_clauses: list[str],
) -> Remediation:
    """Ask the LLM to distill an UNREALISTIC realism verdict into a
    structured remediation. Returns ``Remediation`` (which may be
    NONE if no safe fix is possible).

    v2: delegates to ``FeedbackDistillAgent`` (C2 step 2). The function
    survives as the public API for callers (pipeline + existing tests)
    — it owns the verdict-gating policy and converts agent failures
    into a clean ``Remediation(scope=NONE, rationale=...)`` so the
    pipeline never crashes from a single feedback call.
    """
    from bmc_agent.realism_checker import RealismVerdict

    # Fire on UNREALISTIC (definite artifact — distill an invariant)
    # AND on UNCERTAIN (LLM couldn't decide — usually because of missing
    # context, which is itself a code-change signal). REALISTIC verdicts
    # are the bug signal we want to preserve; never distill them.
    if realism.verdict not in (RealismVerdict.UNREALISTIC, RealismVerdict.UNCERTAIN):
        return Remediation(scope=RemediationScope.NONE,
                           rationale="Feedback loop only fires on UNREALISTIC/UNCERTAIN.")

    # Lazy import to avoid the agents package importing feedback_loop
    # back at module load time.
    from bmc_agent.agents.feedback_distill import FeedbackDistillAgent

    agent = FeedbackDistillAgent(config=config, llm=llm)
    result = agent.run(
        func=func,
        counterexample=counterexample,
        realism=realism,
        existing_project_clauses=existing_project_clauses,
    )
    if not result.ok:
        logger.warning(
            "Feedback distillation failed for '%s': %s",
            func.name, (result.error or "")[:200],
        )
        return Remediation(
            scope=RemediationScope.NONE,
            rationale=f"agent: {(result.error or 'no result')[:200]}",
        )
    return result.output


def _parse_remediation(raw: str, func_name: str) -> Remediation:
    """Parse the LLM's distill response."""
    text = raw.strip()
    # Strip markdown fence
    if text.startswith("```"):
        lines = text.splitlines()
        inner: list[str] = []
        in_fence = False
        for line in lines:
            if line.startswith("```"):
                in_fence = not in_fence
                continue
            if in_fence:
                inner.append(line)
        text = "\n".join(inner).strip() or text

    try:
        data = json.loads(text)
    except json.JSONDecodeError:
        # Try to extract first balanced JSON object
        from bmc_agent.realism_checker import _extract_first_json_object
        embedded = _extract_first_json_object(text)
        if embedded is None:
            logger.warning(
                "Feedback: failed to parse distill response for '%s': %s",
                func_name, raw[:200],
            )
            return Remediation(scope=RemediationScope.NONE,
                               rationale="Could not parse LLM response.")
        try:
            data = json.loads(embedded)
        except json.JSONDecodeError:
            return Remediation(scope=RemediationScope.NONE,
                               rationale="Embedded JSON also unparseable.")

    scope_str = str(data.get("scope", "none")).lower().strip()
    scope_map = {
        "code-change": RemediationScope.CODE_CHANGE,
        "function-spec": RemediationScope.FUNCTION_SPEC,
        "project-invariant": RemediationScope.PROJECT_INVARIANT,
        "function-post-relax": RemediationScope.FUNCTION_POST_RELAX,
        "none": RemediationScope.NONE,
    }
    scope = scope_map.get(scope_str, RemediationScope.NONE)
    return Remediation(
        scope=scope,
        clause=str(data.get("clause", "")).strip(),
        code_change=str(data.get("code_change", "")).strip(),
        rationale=str(data.get("rationale", "")).strip(),
        confidence=str(data.get("confidence", "low")).lower().strip(),
    )


# ---------------------------------------------------------------------------
# Persistence
# ---------------------------------------------------------------------------


class LearnedConstraintsStore:
    """File-backed store for learned constraints + code-change TODOs.

    The store lives under ``<artifact_dir>/learned_constraints.json``
    (per-project) so future sweeps on the same project automatically
    pick up the invariants without re-deriving them.
    """

    SCHEMA_VERSION = 1
    FILENAME = "learned_constraints.json"

    def __init__(self, artifact_dir: str | os.PathLike) -> None:
        self.path = Path(artifact_dir) / self.FILENAME
        self._data = self._load()

    def _load(self) -> dict:
        if not self.path.exists():
            return {
                "version": self.SCHEMA_VERSION,
                "function_clauses": {},
                "project_clauses": [],
                "code_change_todos": [],
            }
        try:
            with self.path.open("r", encoding="utf-8") as f:
                data = json.load(f)
            if data.get("version") != self.SCHEMA_VERSION:
                logger.warning(
                    "learned_constraints.json schema version mismatch — ignoring"
                )
                return self._fresh()
            return data
        except Exception as exc:
            logger.warning(
                "Failed to load learned_constraints.json (%s) — starting fresh",
                exc,
            )
            return self._fresh()

    @classmethod
    def _fresh(cls) -> dict:
        return {
            "version": cls.SCHEMA_VERSION,
            "function_clauses": {},
            "project_clauses": [],
            "code_change_todos": [],
            # FUT POST relaxations: clauses to DROP from a function's
            # postcondition on the next run (over-tight LLM-emitted
            # POST clauses that real implementations don't satisfy).
            "function_post_relaxations": {},
        }

    def project_clauses(self) -> list[str]:
        return list(self._data.get("project_clauses", []))

    def function_clauses(self, func_name: str) -> list[str]:
        return list(self._data.get("function_clauses", {}).get(func_name, []))

    def function_post_relaxations(self, func_name: str) -> list[str]:
        """Return the list of POST clauses to DROP from *func_name*'s
        postcondition on the next harness emission. Triggered by the
        FUNCTION_POST_RELAX scope when realism rejects a FUT-POST
        violation.
        """
        return list(
            self._data.get("function_post_relaxations", {}).get(func_name, [])
        )

    def record(self, func_name: str, r: Remediation,
               source_property: str = "") -> bool:
        """Persist a remediation. Returns True if anything new was added.

        Side-effect: when the same clause has now been learned for 3+
        distinct functions, auto-promote it to ``project_clauses`` and
        retire the per-function copies. This catches project-wide
        invariants the LLM was learning incrementally function-by-function
        (e.g. ``ctxt != NULL`` independently re-derived for several
        xmlXInclude* functions before being formally voted as a project
        invariant).
        """
        changed = False
        if r.scope == RemediationScope.FUNCTION_SPEC and r.clause:
            slot = self._data.setdefault("function_clauses", {}).setdefault(func_name, [])
            if r.clause not in slot:
                slot.append(r.clause)
                changed = True
                logger.info("Learned function-spec clause for '%s': %s",
                            func_name, r.clause[:100])
                # Auto-promotion check.
                if self._maybe_promote_to_project(r.clause):
                    changed = True
        elif r.scope == RemediationScope.PROJECT_INVARIANT and r.clause:
            # Write-time gate: a clause that references function-local-style
            # identifiers (short names, `_`-prefixed) cannot be a genuine
            # project-wide invariant — those names are parameter conventions
            # in a single module, not project-global symbols. Demote to
            # function-spec scope for the source function so the data isn't
            # lost, but keep it out of project_clauses where it would
            # contaminate every other function's harness. Companion to
            # the read-time filter in harness_generator (ed48fb9).
            from bmc_agent.harness_generator import clause_has_param_style_ident
            if clause_has_param_style_ident(r.clause):
                logger.info(
                    "Refusing to promote project_invariant with function-local-"
                    "style ident; demoting to function-spec for '%s': %s",
                    func_name, r.clause[:100],
                )
                slot = self._data.setdefault("function_clauses", {}).setdefault(func_name, [])
                if r.clause not in slot:
                    slot.append(r.clause)
                    changed = True
            else:
                slot = self._data.setdefault("project_clauses", [])
                if r.clause not in slot:
                    slot.append(r.clause)
                    changed = True
                    logger.info("Learned project invariant: %s", r.clause[:100])
        elif r.scope == RemediationScope.FUNCTION_POST_RELAX and r.clause:
            slot = (
                self._data.setdefault("function_post_relaxations", {})
                .setdefault(func_name, [])
            )
            if r.clause not in slot:
                slot.append(r.clause)
                changed = True
                logger.info(
                    "Learned function-post relaxation for '%s' (drop clause): %s",
                    func_name, r.clause[:100],
                )
        elif r.scope == RemediationScope.CODE_CHANGE and r.code_change:
            slot = self._data.setdefault("code_change_todos", [])
            entry = {
                "description": r.code_change,
                "from_function": func_name,
                "from_property": source_property,
                "rationale": r.rationale,
                "confidence": r.confidence,
            }
            # Dedup on (description, from_function)
            key = (r.code_change, func_name)
            existing_keys = {
                (e.get("description"), e.get("from_function")) for e in slot
            }
            if key not in existing_keys:
                slot.append(entry)
                changed = True
                logger.info(
                    "Recorded code-change TODO from '%s': %s",
                    func_name, r.code_change[:120],
                )
        if changed:
            self._save()
        return changed

    # Threshold: ≥3 distinct functions independently learn the same
    # clause → promote to project_clauses. Tuned conservatively;
    # individual function clauses are safer than project clauses,
    # so we only promote when the LLM has converged on the same
    # invariant for several independent functions.
    PROMOTION_THRESHOLD = 3

    def _maybe_promote_to_project(self, clause: str) -> bool:
        """Promote a clause to project_clauses if ≥PROMOTION_THRESHOLD
        functions have learned it. Returns True if a promotion happened.
        """
        if not clause:
            return False
        # Write-time gate (mirrors the direct-PROJECT_INVARIANT path):
        # ≥3 sibling functions in the same module often share parameter
        # names (e.g. archive_acl_*'s ``acl`` first param), which makes
        # the auto-promotion threshold a false signal for project-wide
        # invariants. Keep the clause in per-function slots — they're
        # still useful there.
        from bmc_agent.harness_generator import clause_has_param_style_ident
        if clause_has_param_style_ident(clause):
            logger.debug(
                "Skipping project_clauses promotion — clause references "
                "function-local-style identifier: %s", clause[:100],
            )
            return False
        per_fn = self._data.get("function_clauses", {}) or {}
        owners = [fn for fn, cs in per_fn.items() if clause in cs]
        if len(owners) < self.PROMOTION_THRESHOLD:
            return False
        proj = self._data.setdefault("project_clauses", [])
        if clause in proj:
            # Already promoted; just clean up per-function copies.
            for fn in owners:
                per_fn[fn] = [c for c in per_fn[fn] if c != clause]
            # Drop empty function entries.
            for fn in list(per_fn.keys()):
                if not per_fn[fn]:
                    del per_fn[fn]
            return True
        proj.append(clause)
        # Retire the per-function copies now that it lives at project scope.
        for fn in owners:
            per_fn[fn] = [c for c in per_fn[fn] if c != clause]
        for fn in list(per_fn.keys()):
            if not per_fn[fn]:
                del per_fn[fn]
        logger.info(
            "Auto-promoted clause to project_clauses (learned by %d functions): %s",
            len(owners), clause[:80],
        )
        return True

    def compact(self) -> int:
        """Scan all per-function clauses; promote any that have been
        independently learned by ≥PROMOTION_THRESHOLD functions to the
        project_clauses list. Useful for migrating a store created by
        an older bmc-agent that lacked auto-promotion. Returns the
        number of clauses promoted.
        """
        per_fn = self._data.get("function_clauses", {}) or {}
        # Count owners per clause.
        from collections import Counter
        counter: Counter = Counter()
        for fn, cs in per_fn.items():
            for c in cs:
                counter[c] += 1
        promoted = 0
        changed = False
        for clause, owners in counter.items():
            if owners >= self.PROMOTION_THRESHOLD:
                if self._maybe_promote_to_project(clause):
                    promoted += 1
                    changed = True
        if changed:
            self._save()
        return promoted

    def cleanup_contaminated_project_clauses(self) -> int:
        """Remove clauses from project_clauses that reference param-style
        identifiers (short names, ``_``-prefixed). These can exist in
        legacy stores created before the write-time gate (2f19a1c) was
        added. Returns the number of clauses removed.

        Contaminated clauses get demoted to function_clauses if the
        ``from_function`` provenance is unknown (it isn't tracked for
        project_clauses) — so we just delete them and let the next
        sweep relearn them at the proper scope. Safer than guessing.
        """
        from bmc_agent.harness_generator import clause_has_param_style_ident
        proj = self._data.get("project_clauses", []) or []
        kept: list[str] = []
        removed = 0
        for clause in proj:
            if clause_has_param_style_ident(clause):
                logger.info(
                    "cleanup: removing contaminated project clause "
                    "(references param-style ident): %s", clause[:120],
                )
                removed += 1
            else:
                kept.append(clause)
        if removed:
            self._data["project_clauses"] = kept
            self._save()
        return removed

    def _save(self) -> None:
        self.path.parent.mkdir(parents=True, exist_ok=True)
        with self.path.open("w", encoding="utf-8") as f:
            json.dump(self._data, f, indent=2, sort_keys=True)

    def summary(self) -> dict:
        return {
            "project_clauses": len(self._data.get("project_clauses", [])),
            "functions_with_clauses": len(self._data.get("function_clauses", {})),
            "code_change_todos": len(self._data.get("code_change_todos", [])),
        }