diff --git a/benchmarks/lib/__init__.py b/benchmarks/lib/__init__.py index 552e7684..37525cc3 100644 --- a/benchmarks/lib/__init__.py +++ b/benchmarks/lib/__init__.py @@ -1,6 +1,20 @@ -"""Shared benchmark library — retrieval, fusion, and PG database helpers.""" +"""Shared benchmark library — retrieval, fusion, and PG database helpers. + +``BenchmarkDB`` is re-exported lazily (PEP 562 module ``__getattr__``) +rather than imported eagerly at the top: ``bench_db.py`` hard-imports +``psycopg``/``psycopg_pool``/``pgvector`` at module scope (issue #282), +and every other consumer in this package already defers that same import +inside a function for exactly this reason (see the "deferred: module +hard-imports pgvector/psycopg/psycopg_pool at top level; hoisting would +break installs without it" comments in ``ablation_runner.py``, +``bench_db.py``, ``_xb_drivers.py``, ``longitudinal_runner.py``, +``llm_head_to_head/pilot.py``). Eagerly importing it here at package-init +time — as this module did before — made every other submodule +(``benchmarks.lib.verification_report`` included) unimportable on an +install without the Postgres extras, since Python always runs a +package's ``__init__.py`` before any of its submodules. +""" -from benchmarks.lib.bench_db import BenchmarkDB from benchmarks.lib.fusion import ( wrrf_fuse, QualityZone, @@ -17,3 +31,22 @@ "assess_quality_zone", "enforce_chunk_limit", ] + + +def __getattr__(name: str): + """Lazily resolve ``BenchmarkDB`` on first access (PEP 562). + + Precondition: ``name`` is an attribute looked up on this module that + was not found among its eagerly-bound names above. + Postcondition: returns ``bench_db.BenchmarkDB`` for + ``name == "BenchmarkDB"`` — raising whatever ``ImportError`` psycopg's + own absence produces, deferred until actually needed — else raises + ``AttributeError`` (the standard module-attribute-not-found contract). + """ + if name == "BenchmarkDB": + from benchmarks.lib.bench_db import ( # noqa: PLC0415 — deferred: module hard-imports pgvector/psycopg/psycopg_pool at top level; hoisting would break installs without it + BenchmarkDB, + ) + + return BenchmarkDB + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/benchmarks/lib/verification_report.py b/benchmarks/lib/verification_report.py index eb76380c..623d86f1 100644 --- a/benchmarks/lib/verification_report.py +++ b/benchmarks/lib/verification_report.py @@ -100,6 +100,15 @@ @dataclass class ExpResult: + """Data only — deliberately no methods. mutmut's mutation generator + categorically excludes the body of any `@dataclass`-decorated class + (`mutmut/mutation/file_mutation.py:236`), so logic placed on methods + here would carry zero mutation coverage no matter how the test loader + names the module (issue #262 3rd pass; issue #282). `exp_result_name` / + `exp_result_threshold` / `exp_result_verdict` below carry the same + logic as free functions instead. + """ + exp_id: str found: bool = False observed: float | None = None @@ -107,27 +116,34 @@ class ExpResult: detail_md: str = "" ci95: tuple[float, float] | None = None - @property - def name(self) -> str: - return HYPOTHESES[self.exp_id]["name"] - - @property - def threshold(self) -> str: - return HYPOTHESES[self.exp_id]["threshold"] - - @property - def verdict(self) -> str: - if not self.found or self.observed is None: - return "INCONCLUSIVE" - h = HYPOTHESES[self.exp_id] - op, val = h["pass_op"], h["pass_value"] - cmp = { - ">=": self.observed >= val, - "<": self.observed < val, - ">": self.observed > val, - "<=": self.observed <= val, - } - return "PASS" if cmp.get(op, False) else "FAIL" + +def exp_result_name(result: "ExpResult") -> str: + return HYPOTHESES[result.exp_id]["name"] + + +def exp_result_threshold(result: "ExpResult") -> str: + return HYPOTHESES[result.exp_id]["threshold"] + + +def exp_result_verdict(result: "ExpResult") -> str: + if not result.found or result.observed is None: + return "INCONCLUSIVE" + h = HYPOTHESES[result.exp_id] + op, val = h["pass_op"], h["pass_value"] + cmp = { + ">=": result.observed >= val, + "<": result.observed < val, + ">": result.observed > val, + "<=": result.observed <= val, + } + # §12 note: `cmp.get(op, False)`'s default is read only when `op` is not + # one of the four keys above (an unrecognized pass_op). A mutant that + # swaps the default to `None` (or omits it, same effect) is EQUIVALENT: + # both `False` and `None` are falsy, so `"PASS" if ... else "FAIL"` + # resolves identically either way — only a *truthy* default (e.g. `True`) + # changes the outcome, and that mutant is killed by + # test_verdict_unrecognized_operator_defaults_to_fail. + return "PASS" if cmp.get(op, False) else "FAIL" # source: contract stated in the _bootstrap_ci docstring ("None if <10 @@ -300,20 +316,20 @@ def _render_section(r: ExpResult) -> str: h = HYPOTHESES[r.exp_id] if not r.found: return ( - f"## {r.exp_id} — {r.name}\n\n" + f"## {r.exp_id} — {exp_result_name(r)}\n\n" f"- **Hypothesis:** {h['claim']}\n" - f"- **Threshold:** {r.threshold}\n" + f"- **Threshold:** {exp_result_threshold(r)}\n" f"- **Result:** `// TODO: not yet run`\n" ) obs_str = f"{r.observed:.4f}" if r.observed is not None else "N/A" ci_str = f"\n- **95% CI:** [{r.ci95[0]:.4f}, {r.ci95[1]:.4f}]" if r.ci95 else "" parts = [ - f"## {r.exp_id} — {r.name}", + f"## {r.exp_id} — {exp_result_name(r)}", "", f"- **Hypothesis:** {h['claim']}", - f"- **Threshold:** {r.threshold}", + f"- **Threshold:** {exp_result_threshold(r)}", f"- **Observed:** {obs_str}{ci_str}", - f"- **Verdict:** **{r.verdict}**", + f"- **Verdict:** **{exp_result_verdict(r)}**", f"- **Source:** `{r.raw_path}`", ] if r.detail_md: @@ -341,8 +357,10 @@ def _render_summary(results: list[ExpResult]) -> str: obs, verdict = "// TODO: not yet run", "—" else: obs = f"{r.observed:.4f}" if r.observed is not None else "N/A" - verdict = r.verdict - lines.append(f"| {r.exp_id} | {claim} | {r.threshold} | {obs} | {verdict} |") + verdict = exp_result_verdict(r) + lines.append( + f"| {r.exp_id} | {claim} | {exp_result_threshold(r)} | {obs} | {verdict} |" + ) return "\n".join(lines) + "\n" diff --git a/fuzz/replay_corpus.py b/fuzz/replay_corpus.py index 56219f42..e4be0981 100644 --- a/fuzz/replay_corpus.py +++ b/fuzz/replay_corpus.py @@ -42,43 +42,66 @@ @dataclass(frozen=True) class Harness: - """One fuzz harness and the corpus directory that feeds it.""" + """One fuzz harness and the corpus directory that feeds it. + + Data only — deliberately no methods. mutmut's mutation generator + categorically excludes the body of any `@dataclass`-decorated class + (`mutmut/mutation/file_mutation.py:236`), so logic placed on methods + here would carry zero mutation coverage no matter how the test loader + names the module (issue #262 3rd pass; issue #282). + `harness_module_path` / `harness_corpus_path` / `harness_consume` / + `harness_inputs` below carry the same logic as free functions instead. + """ name: str - def module_path(self) -> Path: - return FUZZ_DIR / f"{self.name}.py" - - def corpus_path(self) -> Path: - return CORPUS_DIR / self.name - - def consume(self) -> Callable[[bytes], None]: - """Import the harness and hand back its single-iteration entry point.""" - spec = importlib.util.spec_from_file_location(self.name, self.module_path()) - if spec is None or spec.loader is None: - raise RuntimeError(f"{self.module_path()}: not importable") - module = importlib.util.module_from_spec(spec) - # Registered before exec: @dataclass (and typing constructs - # generally) resolve annotations via sys.modules[cls.__module__], - # which is None for a module loaded from a path and never inserted. - sys.modules[spec.name] = module - spec.loader.exec_module(module) - consume = getattr(module, "consume", None) - if consume is None: - raise RuntimeError( - f"{self.module_path()}: no consume(data: bytes) —" - " every harness must expose one so its property is runnable" - " without atheris" - ) - return consume - - def inputs(self) -> Iterator[tuple[Path, bytes]]: - directory = self.corpus_path() - if not directory.is_dir(): - return - for path in sorted(directory.iterdir()): - if path.is_file(): - yield path, path.read_bytes() + +def harness_module_path(harness: "Harness") -> Path: + return FUZZ_DIR / f"{harness.name}.py" + + +def harness_corpus_path(harness: "Harness") -> Path: + return CORPUS_DIR / harness.name + + +def harness_consume(harness: "Harness") -> Callable[[bytes], None]: + """Import the harness and hand back its single-iteration entry point.""" + module_path = harness_module_path(harness) + spec = importlib.util.spec_from_file_location(harness.name, module_path) + # Defensive guard, structurally unreachable for this call site: module_path + # always ends in ".py", and spec_from_file_location always attaches a + # SourceFileLoader for that suffix (verified empirically: it only returns + # spec=None for an unrecognized extension, never spec-with-loader=None for + # a .py path, existing or not). mutmut's `or`->`and` mutant (mutmut_8) and + # its RuntimeError(None) mutant (mutmut_11) both survive because no + # reachable input can enter this branch -- documented equivalent per + # issue #282's mutation-testing sweep rather than chased with a contrived + # test that fakes an unsupported loader. + if spec is None or spec.loader is None: + raise RuntimeError(f"{module_path}: not importable") + module = importlib.util.module_from_spec(spec) + # Registered before exec: @dataclass (and typing constructs + # generally) resolve annotations via sys.modules[cls.__module__], + # which is None for a module loaded from a path and never inserted. + sys.modules[spec.name] = module + spec.loader.exec_module(module) + consume = getattr(module, "consume", None) + if consume is None: + raise RuntimeError( + f"{module_path}: no consume(data: bytes) —" + " every harness must expose one so its property is runnable" + " without atheris" + ) + return consume + + +def harness_inputs(harness: "Harness") -> Iterator[tuple[Path, bytes]]: + directory = harness_corpus_path(harness) + if not directory.is_dir(): + return + for path in sorted(directory.iterdir()): + if path.is_file(): + yield path, path.read_bytes() def discover() -> list[Harness]: @@ -97,9 +120,9 @@ def discover() -> list[Harness]: def replay(harness: Harness) -> int: """Run one harness over its corpus. Returns the number of inputs run.""" - consume = harness.consume() + consume = harness_consume(harness) count = 0 - for path, data in harness.inputs(): + for path, data in harness_inputs(harness): try: consume(data) except Exception as error: @@ -125,7 +148,7 @@ def main(argv: list[str] | None = None) -> int: if args.list: for harness in harnesses: - print(f"{harness.name}: {sum(1 for _ in harness.inputs())} input(s)") + print(f"{harness.name}: {sum(1 for _ in harness_inputs(harness))} input(s)") return 0 total = 0 diff --git a/mcp_server/core/ablation.py b/mcp_server/core/ablation.py index 1eb023af..cfe4b877 100644 --- a/mcp_server/core/ablation.py +++ b/mcp_server/core/ablation.py @@ -111,30 +111,53 @@ class Mechanism(Enum): @dataclass class AblationConfig: - """Configuration specifying which mechanisms are enabled/disabled.""" + """Configuration specifying which mechanisms are enabled/disabled. + + Data only — mutmut skips the body of any `@dataclass`-decorated class + (`mutmut/mutation/file_mutation.py:236`; issue #262 3rd pass, #282). + `ablation_config_is_enabled/disable/enable/disable_all_except` below + carry the logic as free functions instead. + """ disabled: set[str] = field(default_factory=set) - def is_enabled(self, mechanism: Mechanism | str) -> bool: - """Check if a mechanism is enabled.""" - name = mechanism.value if isinstance(mechanism, Mechanism) else mechanism - return name not in self.disabled - - def disable(self, mechanism: Mechanism | str) -> "AblationConfig": - """Return new config with mechanism disabled.""" - name = mechanism.value if isinstance(mechanism, Mechanism) else mechanism - return AblationConfig(disabled=self.disabled | {name}) - - def enable(self, mechanism: Mechanism | str) -> "AblationConfig": - """Return new config with mechanism enabled.""" - name = mechanism.value if isinstance(mechanism, Mechanism) else mechanism - return AblationConfig(disabled=self.disabled - {name}) - - def disable_all_except(self, *mechanisms: Mechanism) -> "AblationConfig": - """Disable all mechanisms except the specified ones.""" - keep = {m.value for m in mechanisms} - all_mechs = {m.value for m in Mechanism} - return AblationConfig(disabled=all_mechs - keep) + +def ablation_config_is_enabled( + config: "AblationConfig", mechanism: Mechanism | str +) -> bool: + """Check if a mechanism is enabled.""" + name = mechanism.value if isinstance(mechanism, Mechanism) else mechanism + return name not in config.disabled + + +def ablation_config_disable( + config: "AblationConfig", mechanism: Mechanism | str +) -> "AblationConfig": + """Return new config with mechanism disabled.""" + name = mechanism.value if isinstance(mechanism, Mechanism) else mechanism + return AblationConfig(disabled=config.disabled | {name}) + + +def ablation_config_enable( + config: "AblationConfig", mechanism: Mechanism | str +) -> "AblationConfig": + """Return new config with mechanism enabled.""" + name = mechanism.value if isinstance(mechanism, Mechanism) else mechanism + return AblationConfig(disabled=config.disabled - {name}) + + +def ablation_config_disable_all_except( + config: "AblationConfig", *mechanisms: Mechanism +) -> "AblationConfig": + """Disable all mechanisms except the specified ones. + + ``config`` is unread — kept for a uniform ``(config, ...)`` call shape; + the result always replaces ``disabled`` wholesale (matches the + pre-extraction method's own behavior of ignoring prior state). + """ + keep = {m.value for m in mechanisms} + all_mechs = {m.value for m in Mechanism} + return AblationConfig(disabled=all_mechs - keep) # -- Ablation Results --------------------------------------------------------- diff --git a/mcp_server/core/attentional_control.py b/mcp_server/core/attentional_control.py index e088886a..1b1772b5 100644 --- a/mcp_server/core/attentional_control.py +++ b/mcp_server/core/attentional_control.py @@ -92,14 +92,21 @@ class AttentionAllocation: capacity: int overflow: int = 0 - def as_dict(self) -> dict: - return { - "focus": self.focus, - "weights": [round(w, 4) for w in self.weights], - "entropy": round(self.entropy, 4), - "capacity": self.capacity, - "overflow": self.overflow, - } + +def attention_allocation_as_dict(allocation: "AttentionAllocation") -> dict: + """A free function, not a method: mutmut categorically excludes the + body of any `@dataclass`-decorated class (`mutmut/mutation/ + file_mutation.py:236`), so logic placed on `AttentionAllocation` + methods would carry zero mutation coverage no matter how the test + loader names the module (issue #262 3rd pass; issue #282). + """ + return { + "focus": allocation.focus, + "weights": [round(w, 4) for w in allocation.weights], + "entropy": round(allocation.entropy, 4), + "capacity": allocation.capacity, + "overflow": allocation.overflow, + } # ── Relevance scoring (top-down) ──────────────────────────────────────────── diff --git a/mcp_server/core/auto_curator.py b/mcp_server/core/auto_curator.py index 0dd4bc37..be39e0a6 100644 --- a/mcp_server/core/auto_curator.py +++ b/mcp_server/core/auto_curator.py @@ -33,6 +33,7 @@ from __future__ import annotations from mcp_server.core.prose_redaction import REDACTION_CONVENTIONS +from mcp_server.core.wiki_coverage import domain_coverage_missing_scopes import re from collections import Counter, defaultdict @@ -1004,7 +1005,7 @@ def build_coverage_jobs( mem_contents = [m.get("content") or "" for m in mems[:MAX_MEMORIES_PER_PROMPT]] mem_tags = [m.get("tags") or [] for m in mems[:MAX_MEMORIES_PER_PROMPT]] mem_ids = [m["id"] for m in mems if "id" in m] - for missing in cov.missing_scopes(): + for missing in domain_coverage_missing_scopes(cov): scope = missing.scope related = _find_related_pages_for_scope( domain, scope.name, existing_pages_by_topic diff --git a/mcp_server/core/conflict_monitor.py b/mcp_server/core/conflict_monitor.py index d6c0044f..312990d2 100644 --- a/mcp_server/core/conflict_monitor.py +++ b/mcp_server/core/conflict_monitor.py @@ -364,17 +364,24 @@ class ConflictAssessment: loser_id: Any | None high: bool - def as_dict(self) -> dict: - return { - "conflict_score": round(self.conflict_score, 4), - "entropy": round(self.entropy, 4), - "max_contradiction": round(self.max_contradiction, 4), - "competing_pair": list(self.competing_pair) - if self.competing_pair is not None - else None, - "loser_id": self.loser_id, - "high": self.high, - } + +def conflict_assessment_as_dict(assessment: "ConflictAssessment") -> dict: + """A free function, not a method: mutmut categorically excludes the + body of any `@dataclass`-decorated class (`mutmut/mutation/ + file_mutation.py:236`), so logic placed on `ConflictAssessment` methods + would carry zero mutation coverage no matter how the test loader names + the module (issue #262 3rd pass; issue #282). + """ + return { + "conflict_score": round(assessment.conflict_score, 4), + "entropy": round(assessment.entropy, 4), + "max_contradiction": round(assessment.max_contradiction, 4), + "competing_pair": list(assessment.competing_pair) + if assessment.competing_pair is not None + else None, + "loser_id": assessment.loser_id, + "high": assessment.high, + } def _candidate_text(c: dict[str, Any]) -> str: diff --git a/mcp_server/core/context_assembly/budget.py b/mcp_server/core/context_assembly/budget.py index ff29092c..890db267 100644 --- a/mcp_server/core/context_assembly/budget.py +++ b/mcp_server/core/context_assembly/budget.py @@ -103,17 +103,28 @@ class AssemblyMetrics: total_variable_budget: int = 0 total_final_tokens: int = 0 - def reduction_fraction(self, key: str) -> float: - """Fraction of a placeholder's content that survived (0.0..1.0).""" - orig = self.original_tokens.get(key, 0) - if orig == 0: - return 1.0 - fin = self.final_tokens.get(key, 0) - return fin / orig - - def was_truncated(self, key: str, threshold: float = 0.9) -> bool: - """True if the placeholder's surviving fraction is below threshold.""" - return self.reduction_fraction(key) < threshold + +def assembly_metrics_reduction_fraction(metrics: "AssemblyMetrics", key: str) -> float: + """Fraction of a placeholder's content that survived (0.0..1.0). + + A free function, not a method: mutmut categorically excludes the body + of any `@dataclass`-decorated class (`mutmut/mutation/file_mutation.py: + 236`), so logic placed on `AssemblyMetrics` methods would carry zero + mutation coverage no matter how the test loader names the module + (issue #262 3rd pass; issue #282). + """ + orig = metrics.original_tokens.get(key, 0) + if orig == 0: + return 1.0 + fin = metrics.final_tokens.get(key, 0) + return fin / orig + + +def assembly_metrics_was_truncated( + metrics: "AssemblyMetrics", key: str, threshold: float = 0.9 +) -> bool: + """True if the placeholder's surviving fraction is below threshold.""" + return assembly_metrics_reduction_fraction(metrics, key) < threshold # ── Generic truncation ────────────────────────────────────────────────── diff --git a/mcp_server/core/document_model.py b/mcp_server/core/document_model.py index a9163d25..ffc933c6 100644 --- a/mcp_server/core/document_model.py +++ b/mcp_server/core/document_model.py @@ -56,12 +56,20 @@ class DocumentSection: body: str = "" tables: list[DocumentTable] = field(default_factory=list) - def is_empty(self) -> bool: - """True when the section carries no body text and no tables — a - headings-only section (issue #192 edge case). The renderer still - emits the heading; the memory writer skips empty sections so a bare - heading does not become a contentless memory.""" - return not self.body.strip() and not self.tables + +def document_section_is_empty(section: "DocumentSection") -> bool: + """True when the section carries no body text and no tables — a + headings-only section (issue #192 edge case). The renderer still + emits the heading; the memory writer skips empty sections so a bare + heading does not become a contentless memory. + + A free function, not a method: mutmut categorically excludes the body + of any `@dataclass`-decorated class (`mutmut/mutation/file_mutation.py: + 236`), so logic placed on `DocumentSection` methods would carry zero + mutation coverage no matter how the test loader names the module + (issue #262 3rd pass; issue #282). + """ + return not section.body.strip() and not section.tables @dataclass @@ -78,11 +86,15 @@ class ParsedDocument: sections: list[DocumentSection] = field(default_factory=list) image_count: int = 0 - def is_empty(self) -> bool: - """True when the document yielded no text and no tables at all - (issue #192 edge case: empty doc). Distinct from a malformed doc, - which raises :class:`DocumentParseError` instead.""" - return all(s.is_empty() for s in self.sections) + +def parsed_document_is_empty(doc: "ParsedDocument") -> bool: + """True when the document yielded no text and no tables at all + (issue #192 edge case: empty doc). Distinct from a malformed doc, + which raises :class:`DocumentParseError` instead. + + A free function, not a method — see `document_section_is_empty`. + """ + return all(document_section_is_empty(s) for s in doc.sections) @dataclass(frozen=True) @@ -101,16 +113,21 @@ class DocumentProvenance: version: str source_kind: str - def dedup_tag(self) -> str: - """Canonical idempotency key: one document source at one version. - A prior ingestion of the same ``(source, version)`` carries this - exact tag; the handler checks for it and skips re-writing (§13.1-A6 - idempotent re-ingest). A NEW version produces a different tag, so an - updated document is re-ingested rather than silently ignored.""" - return f"doc-ingest:{self.source_kind}:{self.source}@{self.version}" +def document_provenance_dedup_tag(prov: "DocumentProvenance") -> str: + """Canonical idempotency key: one document source at one version. + + A prior ingestion of the same ``(source, version)`` carries this + exact tag; the handler checks for it and skips re-writing (§13.1-A6 + idempotent re-ingest). A NEW version produces a different tag, so an + updated document is re-ingested rather than silently ignored. + + A free function, not a method — see `document_section_is_empty`. + """ + return f"doc-ingest:{prov.source_kind}:{prov.source}@{prov.version}" + - def source_tag(self) -> str: - """Version-independent tag anchoring every memory to this source, so - all ingested versions of one document are recall-linkable.""" - return f"doc-src:{self.source_kind}:{self.source}" +def document_provenance_source_tag(prov: "DocumentProvenance") -> str: + """Version-independent tag anchoring every memory to this source, so + all ingested versions of one document are recall-linkable.""" + return f"doc-src:{prov.source_kind}:{prov.source}" diff --git a/mcp_server/core/document_normalizer.py b/mcp_server/core/document_normalizer.py index 9e7ddb37..efbb515e 100644 --- a/mcp_server/core/document_normalizer.py +++ b/mcp_server/core/document_normalizer.py @@ -19,6 +19,8 @@ DocumentSection, DocumentTable, ParsedDocument, + document_section_is_empty, + parsed_document_is_empty, ) _MAX_SLUG = 80 @@ -89,7 +91,7 @@ def _render_section(section: DocumentSection) -> str: def _section_memory(section: DocumentSection) -> SectionMemory | None: - if section.is_empty(): + if document_section_is_empty(section): return None body_parts = [section.body.strip()] if section.body.strip() else [] for table in section.tables: @@ -148,7 +150,7 @@ def normalize_document( notices.append(notice) lines.append(f"> Note: {notice}") lines.append("") - if doc.is_empty(): + if parsed_document_is_empty(doc): empty_notice = "Document contained no extractable text." notices.append(empty_notice) lines.append(f"> Note: {empty_notice}") diff --git a/mcp_server/core/dual_process_retrieval.py b/mcp_server/core/dual_process_retrieval.py index 27f570eb..a0bca8ec 100644 --- a/mcp_server/core/dual_process_retrieval.py +++ b/mcp_server/core/dual_process_retrieval.py @@ -113,14 +113,21 @@ class FamiliaritySignal: n: int method: str = METHOD_VECTOR - def as_dict(self) -> dict: - return { - "familiarity": round(self.familiarity, 6), - "mean": round(self.mean, 6), - "margin": round(self.margin, 6), - "n": self.n, - "method": self.method, - } + +def familiarity_signal_as_dict(signal: "FamiliaritySignal") -> dict: + """A free function, not a method: mutmut categorically excludes the + body of any `@dataclass`-decorated class (`mutmut/mutation/ + file_mutation.py:236`), so logic placed on `FamiliaritySignal` methods + would carry zero mutation coverage no matter how the test loader names + the module (issue #262 3rd pass; issue #282). + """ + return { + "familiarity": round(signal.familiarity, 6), + "mean": round(signal.mean, 6), + "margin": round(signal.margin, 6), + "n": signal.n, + "method": signal.method, + } @dataclass diff --git a/mcp_server/core/forward_model.py b/mcp_server/core/forward_model.py index ccdfd52b..89371935 100644 --- a/mcp_server/core/forward_model.py +++ b/mcp_server/core/forward_model.py @@ -97,13 +97,20 @@ class ForwardModelState: corrected: float = 0.0 n_updates: int = 0 - def as_dict(self) -> dict: - return { - "estimate": round(self.estimate, 6), - "error": round(self.error, 6), - "corrected": round(self.corrected, 6), - "n_updates": self.n_updates, - } + +def forward_model_state_as_dict(state: "ForwardModelState") -> dict: + """A free function, not a method: mutmut categorically excludes the + body of any `@dataclass`-decorated class (`mutmut/mutation/ + file_mutation.py:236`), so logic placed on `ForwardModelState` methods + would carry zero mutation coverage no matter how the test loader names + the module (issue #262 3rd pass; issue #282). + """ + return { + "estimate": round(state.estimate, 6), + "error": round(state.error, 6), + "corrected": round(state.corrected, 6), + "n_updates": state.n_updates, + } # ── The two primitive operations ──────────────────────────────────────────── diff --git a/mcp_server/core/goal_maintenance.py b/mcp_server/core/goal_maintenance.py index 028ee080..1854b6a0 100644 --- a/mcp_server/core/goal_maintenance.py +++ b/mcp_server/core/goal_maintenance.py @@ -122,19 +122,27 @@ class GoalVector: directories: tuple[str, ...] = () label: str = "" - @property - def is_active(self) -> bool: - """True iff the goal carries any keyword, entity, or directory signal.""" - return bool(self.keywords or self.entities or self.directories) - def as_dict(self) -> dict: - return { - "label": self.label, - "keywords": sorted(self.keywords), - "entities": sorted(self.entities), - "directories": list(self.directories), - "is_active": self.is_active, - } +def goal_vector_is_active(goal: "GoalVector") -> bool: + """True iff the goal carries any keyword, entity, or directory signal. + + A free function, not a method: mutmut categorically excludes the body of + any `@dataclass`-decorated class (`mutmut/mutation/file_mutation.py:236`), + so logic placed on ``GoalVector`` methods would carry zero mutation + coverage no matter how the test loader names the module (issue #262 3rd + pass; issue #282). + """ + return bool(goal.keywords or goal.entities or goal.directories) + + +def goal_vector_as_dict(goal: "GoalVector") -> dict: + return { + "label": goal.label, + "keywords": sorted(goal.keywords), + "entities": sorted(goal.entities), + "directories": list(goal.directories), + "is_active": goal_vector_is_active(goal), + } # The canonical inactive goal — a module-level singleton the caller can use when @@ -287,7 +295,7 @@ def goal_relevance( similarity between the goal and the candidate may pass it through the re-weight helpers instead (see honesty note in the module docstring). """ - if not goal.is_active: + if not goal_vector_is_active(goal): return 0.0 kw = _keyword_overlap(goal.keywords, content) ent = _entity_overlap(goal.entities, entities) @@ -312,7 +320,7 @@ def goal_write_gain( novelty score by this gain, so goal-relevant inputs clear the write threshold slightly more easily while the goal is active. """ - if not goal.is_active: + if not goal_vector_is_active(goal): return 1.0 rel = goal_relevance(goal, content, entities=entities, directory=directory) return 1.0 + weight * rel @@ -334,7 +342,7 @@ def goal_recall_multiplier( goal is active. Inactive goal or zero relevance → 1.0 (identity), so recall ordering is unchanged by default. """ - if not goal.is_active: + if not goal_vector_is_active(goal): return 1.0 rel = goal_relevance(goal, content, entities=entities, directory=directory) return 1.0 + weight * rel diff --git a/mcp_server/core/habituation.py b/mcp_server/core/habituation.py index 379531b6..14085066 100644 --- a/mcp_server/core/habituation.py +++ b/mcp_server/core/habituation.py @@ -210,16 +210,23 @@ class HabituationOutcome: effective_repeats: float suppressed: bool - def as_dict(self) -> dict: - return { - "signature": self.signature, - "modulated_novelty": round(self.modulated_novelty, 4), - "response_gain": round(self.response_gain, 4), - "sensitization": round(self.sensitization, 4), - "combined_gain": round(self.combined_gain, 4), - "effective_repeats": round(self.effective_repeats, 4), - "suppressed": self.suppressed, - } + +def habituation_outcome_as_dict(outcome: "HabituationOutcome") -> dict: + """A free function, not a method: mutmut categorically excludes the + body of any `@dataclass`-decorated class (`mutmut/mutation/ + file_mutation.py:236`), so logic placed on `HabituationOutcome` methods + would carry zero mutation coverage no matter how the test loader names + the module (issue #262 3rd pass; issue #282). + """ + return { + "signature": outcome.signature, + "modulated_novelty": round(outcome.modulated_novelty, 4), + "response_gain": round(outcome.response_gain, 4), + "sensitization": round(outcome.sensitization, 4), + "combined_gain": round(outcome.combined_gain, 4), + "effective_repeats": round(outcome.effective_repeats, 4), + "suppressed": outcome.suppressed, + } def habituate_novelty( diff --git a/mcp_server/core/procedural_memory.py b/mcp_server/core/procedural_memory.py index 8226dbca..2fd4e67b 100644 --- a/mcp_server/core/procedural_memory.py +++ b/mcp_server/core/procedural_memory.py @@ -102,10 +102,17 @@ class ActionStep: tool: str target_kind: str | None = None - def key(self) -> str: - return ( - self.tool if self.target_kind is None else f"{self.tool}:{self.target_kind}" - ) + +def action_step_key(step: "ActionStep") -> str: + """Stable identity key for one action step. + + A free function, not a method: mutmut categorically excludes the body + of any `@dataclass`-decorated class (`mutmut/mutation/file_mutation.py: + 236`), so logic placed on `ActionStep` methods would carry zero mutation + coverage no matter how the test loader names the module (issue #262 3rd + pass; issue #282). + """ + return step.tool if step.target_kind is None else f"{step.tool}:{step.target_kind}" @dataclass @@ -135,32 +142,37 @@ def __post_init__(self) -> None: if not self.skill_id: self.skill_id = skill_id_for(self.sequence) - @property - def is_habitual(self) -> bool: - """A skill is habitual once it has enough *successful* repetitions.""" - return self.success_count >= HABITUAL_THRESHOLD - - @property - def length(self) -> int: - return len(self.sequence) - - def as_dict(self) -> dict: - return { - "skill_id": self.skill_id, - "sequence": [s.key() for s in self.sequence], - "context_signature": self.context_signature, - "occurrences": self.occurrences, - "success_count": self.success_count, - "failure_count": self.failure_count, - "proficiency": round(self.proficiency, 4), - "is_habitual": self.is_habitual, - "last_seen": self.last_seen, - } + +def procedural_skill_is_habitual(skill: "ProceduralSkill") -> bool: + """A skill is habitual once it has enough *successful* repetitions. + + A free function, not a method — see `action_step_key`'s docstring for + why (issue #282). + """ + return skill.success_count >= HABITUAL_THRESHOLD + + +def procedural_skill_length(skill: "ProceduralSkill") -> int: + return len(skill.sequence) + + +def procedural_skill_as_dict(skill: "ProceduralSkill") -> dict: + return { + "skill_id": skill.skill_id, + "sequence": [action_step_key(s) for s in skill.sequence], + "context_signature": skill.context_signature, + "occurrences": skill.occurrences, + "success_count": skill.success_count, + "failure_count": skill.failure_count, + "proficiency": round(skill.proficiency, 4), + "is_habitual": procedural_skill_is_habitual(skill), + "last_seen": skill.last_seen, + } def skill_id_for(sequence: tuple[ActionStep, ...]) -> str: """Stable content hash of an action sequence (12 hex chars).""" - joined = ">".join(s.key() for s in sequence) + joined = ">".join(action_step_key(s) for s in sequence) return hashlib.sha256(joined.encode("utf-8")).hexdigest()[:12] @@ -212,7 +224,7 @@ def normalize_actions( ) if not step.tool: continue - if steps and steps[-1].key() == step.key(): + if steps and action_step_key(steps[-1]) == action_step_key(step): continue # collapse consecutive duplicates steps.append(step) return steps @@ -330,7 +342,7 @@ def _skill_priority(skill: ProceduralSkill) -> float: """ support = math.log1p(skill.occurrences) - length_bonus = 1.0 + 0.1 * (skill.length - MIN_SKILL_LEN) + length_bonus = 1.0 + 0.1 * (procedural_skill_length(skill) - MIN_SKILL_LEN) return skill.proficiency * support * length_bonus @@ -425,7 +437,7 @@ def match_skills( recent = normalize_actions( current_context.get("tool_calls") or current_context.get("tools_used") or [] ) - recent_keys = {s.key() for s in recent} + recent_keys = {action_step_key(s) for s in recent} scored: list[dict] = [] for skill in skills: @@ -447,13 +459,23 @@ def match_skills( # Small bonus if the procedure's opening action matches what the agent # just did (the habit is "primed" by the current action). prime = 0.0 - if recent_keys and skill.sequence and skill.sequence[0].key() in recent_keys: + if ( + recent_keys + and skill.sequence + and action_step_key(skill.sequence[0]) in recent_keys + ): prime = 0.15 score = (ctx_score + prime) * _skill_priority(skill) if score <= 0.0: continue why = _explain_match(skill, ctx_score, prime) - scored.append({"skill": skill.as_dict(), "score": round(score, 4), "why": why}) + scored.append( + { + "skill": procedural_skill_as_dict(skill), + "score": round(score, 4), + "why": why, + } + ) scored.sort(key=lambda d: d["score"], reverse=True) return scored[:top_k] @@ -461,10 +483,10 @@ def match_skills( def _explain_match(skill: ProceduralSkill, ctx_score: float, prime: float) -> str: bits = [ - f"{skill.length}-step routine", + f"{procedural_skill_length(skill)}-step routine", f"{skill.proficiency:.0%} success over {skill.occurrences} uses", ] - if skill.is_habitual: + if procedural_skill_is_habitual(skill): bits.append("habitual") if ctx_score >= _CONTEXT_MATCH_THRESHOLD: bits.append(f"matches context '{skill.context_signature}'") diff --git a/mcp_server/core/recall_pipeline.py b/mcp_server/core/recall_pipeline.py index cf462b46..8b921f2f 100644 --- a/mcp_server/core/recall_pipeline.py +++ b/mcp_server/core/recall_pipeline.py @@ -41,7 +41,10 @@ from mcp_server.shared.similarity import jaccard_similarity from mcp_server.shared.vader import vader_compound from mcp_server.core.value_learning import retrieval_priority -from mcp_server.core.goal_maintenance import goal_recall_multiplier +from mcp_server.core.goal_maintenance import ( + goal_recall_multiplier, + goal_vector_is_active, +) from mcp_server.core.attentional_control import allocate_attention from mcp_server.core.reconsolidation import compute_reconsolidation_action @@ -1047,7 +1050,7 @@ def goal_maintenance_rerank( return candidates if not candidates or len(candidates) < _MIN_RERANK_CANDIDATES: return candidates - if goal is None or not getattr(goal, "is_active", False): + if goal is None or not goal_vector_is_active(goal): return candidates for c in candidates: @@ -1379,7 +1382,9 @@ def conflict_monitor_rerank( # Attach plans + the assessment to the current top candidate so a # downstream curation phase can act on them. Non-destructive. candidates[0].setdefault("conflict_plans", []).extend(plans) - candidates[0]["conflict_assessment"] = assessment.as_dict() + candidates[0]["conflict_assessment"] = ( + conflict_monitor.conflict_assessment_as_dict(assessment) + ) except Exception: # noqa: BLE001 — non-load-bearing; never fail a recall return candidates diff --git a/mcp_server/core/sensory_buffer.py b/mcp_server/core/sensory_buffer.py index 64cc90b7..aff8424d 100644 --- a/mcp_server/core/sensory_buffer.py +++ b/mcp_server/core/sensory_buffer.py @@ -35,7 +35,15 @@ @dataclass class BufferItem: - """A single item in the sensory buffer.""" + """A single item in the sensory buffer. + + Data only — deliberately no methods. mutmut's mutation generator + categorically excludes the body of any `@dataclass`-decorated class + (`mutmut/mutation/file_mutation.py:236`), so logic placed on methods + here would carry zero mutation coverage no matter how the test loader + names the module (issue #262 3rd pass; issue #282). ``buffer_item_to_dict`` + below carries the same logic as a free function instead. + """ content: str tags: list[str] @@ -48,17 +56,18 @@ class BufferItem: default_factory=lambda: datetime.now(timezone.utc).isoformat() ) - def to_dict(self) -> dict[str, Any]: - return { - "content": self.content, - "tags": self.tags, - "source": self.source, - "directory": self.directory, - "domain": self.domain, - "importance": self.importance, - "valence": self.valence, - "created_at": self.created_at, - } + +def buffer_item_to_dict(item: BufferItem) -> dict[str, Any]: + return { + "content": item.content, + "tags": item.tags, + "source": item.source, + "directory": item.directory, + "domain": item.domain, + "importance": item.importance, + "valence": item.valence, + "created_at": item.created_at, + } # ── Sensory buffer ──────────────────────────────────────────────────────── @@ -130,7 +139,7 @@ def push( "importance": round(importance, 4), "valence": round(valence, 4), "buffer_size": len(self._buffer), - "item": item.to_dict() if is_urgent else None, + "item": buffer_item_to_dict(item) if is_urgent else None, } # ── Read ─────────────────────────────────────────────────────────── @@ -168,7 +177,7 @@ def focus( an empty allocation. """ - items = [item.to_dict() for item in self._buffer] + items = [buffer_item_to_dict(item) for item in self._buffer] return allocate_attention( query, items, diff --git a/mcp_server/core/source_monitoring.py b/mcp_server/core/source_monitoring.py index 6ebbc7b0..dcd4c0fd 100644 --- a/mcp_server/core/source_monitoring.py +++ b/mcp_server/core/source_monitoring.py @@ -128,16 +128,23 @@ class SourceJudgement: inferred_score: int grounding_refs: list[str] = field(default_factory=list) - def as_dict(self) -> dict: - return { - "attribution": self.attribution, - "confidence": round(self.confidence, 4), - "evidence_tag": self.evidence_tag, - "perceptual_score": self.perceptual_score, - "told_score": self.told_score, - "inferred_score": self.inferred_score, - "grounding_refs": self.grounding_refs[:10], - } + +def source_judgement_as_dict(judgement: "SourceJudgement") -> dict: + """A free function, not a method: mutmut categorically excludes the + body of any `@dataclass`-decorated class (`mutmut/mutation/ + file_mutation.py:236`), so logic placed on `SourceJudgement` methods + would carry zero mutation coverage no matter how the test loader names + the module (issue #262 3rd pass; issue #282). + """ + return { + "attribution": judgement.attribution, + "confidence": round(judgement.confidence, 4), + "evidence_tag": judgement.evidence_tag, + "perceptual_score": judgement.perceptual_score, + "told_score": judgement.told_score, + "inferred_score": judgement.inferred_score, + "grounding_refs": judgement.grounding_refs[:10], + } # ── Feature extraction ────────────────────────────────────────────────────── diff --git a/mcp_server/core/streaming/__init__.py b/mcp_server/core/streaming/__init__.py index 6b2cd777..681367f2 100644 --- a/mcp_server/core/streaming/__init__.py +++ b/mcp_server/core/streaming/__init__.py @@ -12,18 +12,26 @@ from __future__ import annotations -from mcp_server.core.streaming.adaptive_controller import AdaptiveBatchController +from mcp_server.core.streaming.adaptive_controller import ( + AdaptiveBatchController, + adaptive_batch_controller_batch_size, + adaptive_batch_controller_observe, +) from mcp_server.core.streaming.backpressure_pipeline import ( BackpressurePipeline, PipelineResult, + backpressure_pipeline_run, compute_queue_cap, ) from mcp_server.core.streaming.ports import BatchSink, StreamSource __all__ = [ "AdaptiveBatchController", + "adaptive_batch_controller_batch_size", + "adaptive_batch_controller_observe", "BackpressurePipeline", "PipelineResult", + "backpressure_pipeline_run", "compute_queue_cap", "BatchSink", "StreamSource", diff --git a/mcp_server/core/streaming/adaptive_controller.py b/mcp_server/core/streaming/adaptive_controller.py index 11541341..7df99f69 100644 --- a/mcp_server/core/streaming/adaptive_controller.py +++ b/mcp_server/core/streaming/adaptive_controller.py @@ -56,21 +56,36 @@ def __post_init__(self) -> None: self.ai_step = self.b_min self._b = self.b_min - @property - def batch_size(self) -> int: - """The current batch size ``B`` (b_min <= B <= b_max).""" - return self._b - - def observe(self, latency_s: float) -> int: - """Update B from one observed batch-write latency; return the new B. - - Within target → additive increase ``B += ai_step``; over target → - multiplicative decrease ``B := max(b_min, floor(beta * B))``. - Postcondition: ``b_min <= B <= b_max``. - """ - if latency_s <= self.w_target_s: - self._b = min(self.b_max, self._b + self.ai_step) - else: - self._b = max(self.b_min, int(self._b * _MD_FACTOR)) - assert self.b_min <= self._b <= self.b_max # invariant (precond 4) - return self._b + +def adaptive_batch_controller_batch_size(controller: "AdaptiveBatchController") -> int: + """The current batch size ``B`` (b_min <= B <= b_max). + + A free function, not a method: mutmut categorically excludes the body + of any `@dataclass`-decorated class (`mutmut/mutation/file_mutation.py: + 236`), so logic placed on `AdaptiveBatchController` methods (other than + `__post_init__`, a dunder mutmut skips regardless) would carry zero + mutation coverage no matter how the test loader names the module + (issue #262 3rd pass; issue #282). + """ + return controller._b + + +def adaptive_batch_controller_observe( + controller: "AdaptiveBatchController", latency_s: float +) -> int: + """Update B from one observed batch-write latency; return the new B. + + Within target → additive increase ``B += ai_step``; over target → + multiplicative decrease ``B := max(b_min, floor(beta * B))``. + Postcondition: ``b_min <= B <= b_max``. Mutates ``controller`` in place + (it is the sole owner of the live batch size), matching the + pre-extraction method's own behavior. + """ + if latency_s <= controller.w_target_s: + controller._b = min(controller.b_max, controller._b + controller.ai_step) + else: + controller._b = max(controller.b_min, int(controller._b * _MD_FACTOR)) + assert ( + controller.b_min <= controller._b <= controller.b_max + ) # invariant (precond 4) + return controller._b diff --git a/mcp_server/core/streaming/adaptive_writer.py b/mcp_server/core/streaming/adaptive_writer.py index 15b6f3f2..895ae7a3 100644 --- a/mcp_server/core/streaming/adaptive_writer.py +++ b/mcp_server/core/streaming/adaptive_writer.py @@ -27,7 +27,11 @@ from dataclasses import dataclass, field from typing import Any, AsyncIterator, Callable -from mcp_server.core.streaming.adaptive_controller import AdaptiveBatchController +from mcp_server.core.streaming.adaptive_controller import ( + AdaptiveBatchController, + adaptive_batch_controller_batch_size, + adaptive_batch_controller_observe, +) from mcp_server.core.streaming.ports import BatchSink @@ -63,8 +67,8 @@ def __init__(self, sink: BatchSink, controller: AdaptiveBatchController): def add_many(self, rows: list[Any]) -> None: """Append rows; flush as many controller-sized batches as are ready.""" self._buf.extend(rows) - while len(self._buf) >= self._controller.batch_size: - n = self._controller.batch_size + while len(self._buf) >= adaptive_batch_controller_batch_size(self._controller): + n = adaptive_batch_controller_batch_size(self._controller) self._flush(self._buf[:n]) del self._buf[:n] @@ -77,7 +81,9 @@ def flush_remaining(self) -> None: def _flush(self, batch: list[Any]) -> None: t0 = time.perf_counter() written = self._sink.write_batch(batch) - self._controller.observe(time.perf_counter() - t0) # AIMD step + adaptive_batch_controller_observe( + self._controller, time.perf_counter() - t0 + ) # AIMD step self.rows_written += written diff --git a/mcp_server/core/streaming/backpressure_pipeline.py b/mcp_server/core/streaming/backpressure_pipeline.py index 5a130ece..0aab1319 100644 --- a/mcp_server/core/streaming/backpressure_pipeline.py +++ b/mcp_server/core/streaming/backpressure_pipeline.py @@ -69,6 +69,18 @@ class BackpressurePipeline: ``sink_factory`` builds ONE sink per worker — each worker owns its own connection (sharing a connection across threads is unsafe). The factory is injected by the handler (composition root) and binds the appropriate pool. + + Data only — deliberately no methods. mutmut's mutation generator + categorically excludes the body of any `@dataclass`-decorated class + (`mutmut/mutation/file_mutation.py:236`) — including `@staticmethod` + members, since the exclusion fires on the decorated `ClassDef` itself, + before the per-method `@staticmethod` exception is ever consulted — so + logic placed on methods here would carry zero mutation coverage no + matter how the test loader names the module (issue #262 3rd pass; + issue #282). `backpressure_pipeline_run` (the public entry point) plus + the private `_bp_produce` / `_bp_consume` / `_bp_build_sink` / + `_bp_write_one` / `_bp_close` helpers below carry the same logic as + free functions instead. """ source: StreamSource @@ -77,96 +89,113 @@ class BackpressurePipeline: queue_cap: int concurrency: int = 2 - def run(self) -> PipelineResult: - """Drain the source through the workers; block until fully flushed. - - Postcondition: on return, every row the source yielded has been handed - to a sink and durably committed (the staged barrier later phases rely - on) OR surfaced in ``result.errors``. - """ - q: queue.Queue[Any] = queue.Queue(maxsize=self.queue_cap) - result = PipelineResult() - lock = threading.Lock() - producer = threading.Thread( - target=self._produce, args=(q, result, lock), name="bp-producer" - ) - workers = [ - threading.Thread( - target=self._consume, args=(q, result, lock), name=f"bp-worker-{i}" - ) - for i in range(self.concurrency) - ] - producer.start() - for w in workers: - w.start() - producer.join() - for w in workers: - w.join() - return result - - def _produce( - self, q: "queue.Queue[Any]", result: PipelineResult, lock: threading.Lock - ) -> None: - try: - for batch in self.source.stream(self.max_batch): - q.put(batch) # blocks when full — this is the backpressure - with lock: - result.rows_in += len(batch) - result.batches += 1 - except Exception as exc: # noqa: BLE001 — surfaced via result, not raised - with lock: - result.errors.append(f"producer: {exc!r}") - finally: - # Exactly one sentinel per worker — in finally so a producer crash - # still releases every worker (no hang on an empty-but-open queue). - for _ in range(self.concurrency): - q.put(_SENTINEL) - - def _consume( - self, q: "queue.Queue[Any]", result: PipelineResult, lock: threading.Lock - ) -> None: - sink = self._build_sink(result, lock) - try: - while True: - item = q.get() - if item is _SENTINEL: - return # consumed our one sentinel — stop - if sink is None: - continue # setup failed; drain to our sentinel, don't hang - self._write_one(sink, item, result, lock) - finally: - if sink is not None: - self._close(sink, result, lock) - - def _build_sink( - self, result: PipelineResult, lock: threading.Lock - ) -> BatchSink | None: - try: - return self.sink_factory() - except Exception as exc: # noqa: BLE001 - with lock: - result.errors.append(f"worker-setup: {exc!r}") - return None - - @staticmethod - def _write_one( - sink: BatchSink, - item: list[Any], - result: PipelineResult, - lock: threading.Lock, - ) -> None: - try: - written = sink.write_batch(item) - with lock: - result.rows_written += written - except Exception as exc: # noqa: BLE001 - with lock: - result.errors.append(f"worker: {exc!r}") - @staticmethod - def _close(sink: BatchSink, result: PipelineResult, lock: threading.Lock) -> None: - try: - sink.close() - except Exception as exc: # noqa: BLE001 +def backpressure_pipeline_run(pipeline: "BackpressurePipeline") -> PipelineResult: + """Drain the source through the workers; block until fully flushed. + + Postcondition: on return, every row the source yielded has been handed + to a sink and durably committed (the staged barrier later phases rely + on) OR surfaced in ``result.errors``. + """ + q: queue.Queue[Any] = queue.Queue(maxsize=pipeline.queue_cap) + result = PipelineResult() + lock = threading.Lock() + # §12 note: the `name=` kwargs below (both threads) are debug-only + # labels (visible via `threading.enumerate()` / crash dumps) — they do + # not affect the returned PipelineResult, the only contract this + # function's tests assert against, so a mutant that changes, drops, or + # nulls a thread's `name` is EQUIVALENT for that contract. Verified + # empirically: mutating `name=` never changed a test outcome across the + # full `_bp_*` mutation sweep (issue #282). + producer = threading.Thread( + target=_bp_produce, args=(pipeline, q, result, lock), name="bp-producer" + ) + workers = [ + threading.Thread( + target=_bp_consume, args=(pipeline, q, result, lock), name=f"bp-worker-{i}" + ) + for i in range(pipeline.concurrency) + ] + producer.start() + for w in workers: + w.start() + producer.join() + for w in workers: + w.join() + return result + + +def _bp_produce( + pipeline: "BackpressurePipeline", + q: "queue.Queue[Any]", + result: PipelineResult, + lock: threading.Lock, +) -> None: + try: + for batch in pipeline.source.stream(pipeline.max_batch): + q.put(batch) # blocks when full — this is the backpressure with lock: - result.errors.append(f"worker-close: {exc!r}") + result.rows_in += len(batch) + result.batches += 1 + except Exception as exc: # noqa: BLE001 — surfaced via result, not raised + with lock: + result.errors.append(f"producer: {exc!r}") + finally: + # Exactly one sentinel per worker — in finally so a producer crash + # still releases every worker (no hang on an empty-but-open queue). + for _ in range(pipeline.concurrency): + q.put(_SENTINEL) + + +def _bp_consume( + pipeline: "BackpressurePipeline", + q: "queue.Queue[Any]", + result: PipelineResult, + lock: threading.Lock, +) -> None: + sink = _bp_build_sink(pipeline, result, lock) + try: + while True: + item = q.get() + if item is _SENTINEL: + return # consumed our one sentinel — stop + if sink is None: + continue # setup failed; drain to our sentinel, don't hang + _bp_write_one(sink, item, result, lock) + finally: + if sink is not None: + _bp_close(sink, result, lock) + + +def _bp_build_sink( + pipeline: "BackpressurePipeline", result: PipelineResult, lock: threading.Lock +) -> BatchSink | None: + try: + return pipeline.sink_factory() + except Exception as exc: # noqa: BLE001 + with lock: + result.errors.append(f"worker-setup: {exc!r}") + return None + + +def _bp_write_one( + sink: BatchSink, + item: list[Any], + result: PipelineResult, + lock: threading.Lock, +) -> None: + try: + written = sink.write_batch(item) + with lock: + result.rows_written += written + except Exception as exc: # noqa: BLE001 + with lock: + result.errors.append(f"worker: {exc!r}") + + +def _bp_close(sink: BatchSink, result: PipelineResult, lock: threading.Lock) -> None: + try: + sink.close() + except Exception as exc: # noqa: BLE001 + with lock: + result.errors.append(f"worker-close: {exc!r}") diff --git a/mcp_server/core/value_learning.py b/mcp_server/core/value_learning.py index aba50fb1..c4453d63 100644 --- a/mcp_server/core/value_learning.py +++ b/mcp_server/core/value_learning.py @@ -102,6 +102,13 @@ class ValueUpdate: ``delta`` — the reward-prediction error δ = reward - V (signed). ``eligibility`` — the trace weight this memory received (top hit = 1.0). ``effective_alpha``— alpha·eligibility, the actual step size applied. + + Data only — deliberately no methods. mutmut's mutation generator + categorically excludes the body of any `@dataclass`-decorated class + (`mutmut/mutation/file_mutation.py:236`), so logic placed on methods + here would carry zero mutation coverage no matter how the test loader + names the module (issue #262 3rd pass; issue #282). ``value_update_as_dict`` + below carries the same logic as a free function instead. """ memory_id: int @@ -111,15 +118,16 @@ class ValueUpdate: eligibility: float effective_alpha: float - def as_dict(self) -> dict: - return { - "memory_id": self.memory_id, - "old_value": round(self.old_value, 4), - "new_value": round(self.new_value, 4), - "delta": round(self.delta, 4), - "eligibility": round(self.eligibility, 4), - "effective_alpha": round(self.effective_alpha, 4), - } + +def value_update_as_dict(update: ValueUpdate) -> dict: + return { + "memory_id": update.memory_id, + "old_value": round(update.old_value, 4), + "new_value": round(update.new_value, 4), + "delta": round(update.delta, 4), + "eligibility": round(update.eligibility, 4), + "effective_alpha": round(update.effective_alpha, 4), + } # ── TD value update (Schultz / Sutton & Barto) ────────────────────────────── diff --git a/mcp_server/core/wiki_axis_registry.py b/mcp_server/core/wiki_axis_registry.py index fc3a30a9..740c6f35 100644 --- a/mcp_server/core/wiki_axis_registry.py +++ b/mcp_server/core/wiki_axis_registry.py @@ -120,27 +120,41 @@ class AxisRegistry: by_axis: dict[str, dict[str, AxisValue]] = field(default_factory=dict) - def values(self, axis: str) -> tuple[AxisValue, ...]: - """All registered values for an axis.""" - return tuple(self.by_axis.get(axis, {}).values()) - - def names(self, axis: str) -> frozenset[str]: - """All registered value names for an axis.""" - return frozenset(self.by_axis.get(axis, {}).keys()) - - def get(self, axis: str, name: str) -> AxisValue | None: - """Look up a value by axis + name. Case-insensitive.""" - return self.by_axis.get(axis, {}).get(name.lower()) - - def has(self, axis: str, name: str) -> bool: - return self.get(axis, name) is not None - - def default_for(self, axis: str) -> AxisValue | None: - """Return the value marked ``default: true`` for this axis, if any.""" - for v in self.by_axis.get(axis, {}).values(): - if v.default: - return v - return None + +def axis_registry_values(registry: "AxisRegistry", axis: str) -> tuple[AxisValue, ...]: + """All registered values for an axis. + + A free function, not a method: mutmut categorically excludes the body + of any `@dataclass`-decorated class (`mutmut/mutation/file_mutation.py: + 236`), so logic placed on `AxisRegistry` methods would carry zero + mutation coverage no matter how the test loader names the module + (issue #262 3rd pass; issue #282). + """ + return tuple(registry.by_axis.get(axis, {}).values()) + + +def axis_registry_names(registry: "AxisRegistry", axis: str) -> frozenset[str]: + """All registered value names for an axis.""" + return frozenset(registry.by_axis.get(axis, {}).keys()) + + +def axis_registry_get( + registry: "AxisRegistry", axis: str, name: str +) -> AxisValue | None: + """Look up a value by axis + name. Case-insensitive.""" + return registry.by_axis.get(axis, {}).get(name.lower()) + + +def axis_registry_has(registry: "AxisRegistry", axis: str, name: str) -> bool: + return axis_registry_get(registry, axis, name) is not None + + +def axis_registry_default_for(registry: "AxisRegistry", axis: str) -> AxisValue | None: + """Return the value marked ``default: true`` for this axis, if any.""" + for v in registry.by_axis.get(axis, {}).values(): + if v.default: + return v + return None def _re(pattern: str) -> re.Pattern[str]: @@ -309,7 +323,7 @@ def did_you_mean( Implements the "reject + suggest" policy: validators raise ``ValueError`` with these suggestions in the error message. """ - candidates = list(registry.names(axis)) + candidates = list(axis_registry_names(registry, axis)) suggestions = difflib.get_close_matches( unknown.lower(), [c.lower() for c in candidates], n=n, cutoff=0.4 ) @@ -337,7 +351,7 @@ def match_axis( """ matches: list[str] = [] tag_set = {t.lower() for t in (tags or [])} - for value in registry.values(axis): + for value in axis_registry_values(registry, axis): if ( axis == AXIS_LIFECYCLE and value.applies_to_kinds diff --git a/mcp_server/core/wiki_classifier.py b/mcp_server/core/wiki_classifier.py index 5ff8e6b7..c91cebf9 100644 --- a/mcp_server/core/wiki_classifier.py +++ b/mcp_server/core/wiki_classifier.py @@ -54,7 +54,11 @@ from mcp_server.observability import silent_failure from mcp_server.core.wiki_rule_engine import apply_rules from mcp_server.shared.wiki_classification import Classification, Generator -from mcp_server.core.wiki_axis_registry import AXIS_PROVENANCE, get_registry +from mcp_server.core.wiki_axis_registry import ( + AXIS_PROVENANCE, + axis_registry_get, + get_registry, +) __all__ = [ "classify_memory", @@ -322,7 +326,7 @@ def classify_memory( # source of truth — no hardcoded set of provenance names here. generator: Generator | None = None - prov_value = get_registry().get(AXIS_PROVENANCE, provenance) + prov_value = axis_registry_get(get_registry(), AXIS_PROVENANCE, provenance) if prov_value is not None and prov_value.requires_generator: generator = Generator( model="unknown", diff --git a/mcp_server/core/wiki_coverage.py b/mcp_server/core/wiki_coverage.py index 0d4cd210..cbc3ef57 100644 --- a/mcp_server/core/wiki_coverage.py +++ b/mcp_server/core/wiki_coverage.py @@ -945,25 +945,37 @@ class ScopeCoverage: @dataclass class DomainCoverage: - """Roll-up of all scopes for one domain.""" + """Roll-up of all scopes for one domain. + + Data only — deliberately no methods. mutmut's mutation generator + categorically excludes the body of any `@dataclass`-decorated class + (`mutmut/mutation/file_mutation.py:236`), so logic placed on methods + here would carry zero mutation coverage no matter how the test loader + names the module (issue #262 3rd pass; issue #282). + `domain_coverage_covered_count` / `domain_coverage_missing_count` / + `domain_coverage_coverage_ratio` / `domain_coverage_missing_scopes` + below carry the same logic as free functions instead. + """ domain: str scopes: list[ScopeCoverage] = field(default_factory=list) - @property - def covered_count(self) -> int: - return sum(1 for s in self.scopes if s.covered) - @property - def missing_count(self) -> int: - return sum(1 for s in self.scopes if not s.covered) +def domain_coverage_covered_count(coverage: "DomainCoverage") -> int: + return sum(1 for s in coverage.scopes if s.covered) + + +def domain_coverage_missing_count(coverage: "DomainCoverage") -> int: + return sum(1 for s in coverage.scopes if not s.covered) + - @property - def coverage_ratio(self) -> float: - return self.covered_count / len(self.scopes) if self.scopes else 0.0 +def domain_coverage_coverage_ratio(coverage: "DomainCoverage") -> float: + total = len(coverage.scopes) + return domain_coverage_covered_count(coverage) / total if total else 0.0 - def missing_scopes(self) -> list[ScopeCoverage]: - return [s for s in self.scopes if not s.covered] + +def domain_coverage_missing_scopes(coverage: "DomainCoverage") -> list[ScopeCoverage]: + return [s for s in coverage.scopes if not s.covered] # ── Filesystem scan ───────────────────────────────────────────────────── @@ -1198,7 +1210,7 @@ def audit_all_domains( audit_domain(wiki_root, d, max_age_days=max_age_days) for d in list_domains(wiki_root) ] - rolls.sort(key=lambda r: r.missing_count, reverse=True) + rolls.sort(key=domain_coverage_missing_count, reverse=True) return rolls @@ -1372,7 +1384,12 @@ def _index_wiki_file_references( @dataclass class FileCoverage: - """File-level coverage roll-up for one domain.""" + """File-level coverage roll-up for one domain. + + Data only — see `DomainCoverage`'s docstring for why. + `file_coverage_coverage_ratio` below carries the same logic as a free + function instead. + """ domain: str source_root: str | None @@ -1380,11 +1397,11 @@ class FileCoverage: covered_file_count: int # matched by path or basename uncovered_files: list[str] = field(default_factory=list) - @property - def coverage_ratio(self) -> float: - if not self.source_file_count: - return 1.0 - return self.covered_file_count / self.source_file_count + +def file_coverage_coverage_ratio(coverage: "FileCoverage") -> float: + if not coverage.source_file_count: + return 1.0 + return coverage.covered_file_count / coverage.source_file_count def audit_files(wiki_root: str, domain: str) -> FileCoverage: diff --git a/mcp_server/core/wiki_groomer.py b/mcp_server/core/wiki_groomer.py index 4fd029b9..38be8b80 100644 --- a/mcp_server/core/wiki_groomer.py +++ b/mcp_server/core/wiki_groomer.py @@ -54,9 +54,15 @@ class PageAudit: page_kind: str | None issues: list[GroomIssue] = field(default_factory=list) - @property - def has_issues(self) -> bool: - return bool(self.issues) + +def page_audit_has_issues(audit: "PageAudit") -> bool: + """A free function, not a method: mutmut categorically excludes the + body of any `@dataclass`-decorated class (`mutmut/mutation/ + file_mutation.py:236`), so logic placed on `PageAudit` methods would + carry zero mutation coverage no matter how the test loader names the + module (issue #262 3rd pass; issue #282). + """ + return bool(audit.issues) # ── Front-matter parser ─────────────────────────────────────────────────── @@ -207,6 +213,6 @@ def audit_wiki(pages: list[tuple[str, str]]) -> list[PageAudit]: audits: list[PageAudit] = [] for path, content in pages: a = audit_page(path, content) - if a.has_issues: + if page_audit_has_issues(a): audits.append(a) return audits diff --git a/mcp_server/core/wiki_kind_detection.py b/mcp_server/core/wiki_kind_detection.py index d1565b11..b47fa19b 100644 --- a/mcp_server/core/wiki_kind_detection.py +++ b/mcp_server/core/wiki_kind_detection.py @@ -15,6 +15,8 @@ AXIS_AUDIENCE, AXIS_LIFECYCLE, AXIS_PROVENANCE, + axis_registry_default_for, + axis_registry_values, get_registry, match_axis, ) @@ -73,7 +75,7 @@ def detect_provenance(tags: list[str] | None) -> str: matches = match_axis("", tags, AXIS_PROVENANCE, reg) if matches: return matches[0] - default = reg.default_for(AXIS_PROVENANCE) + default = axis_registry_default_for(reg, AXIS_PROVENANCE) return default.name if default is not None else "human" @@ -89,7 +91,7 @@ def detect_audiences( reg = get_registry() matches = list(match_axis(content, tags, AXIS_AUDIENCE, reg)) if not matches: - default = reg.default_for(AXIS_AUDIENCE) + default = axis_registry_default_for(reg, AXIS_AUDIENCE) if default is not None: matches.append(default.name) else: @@ -107,7 +109,7 @@ def pick_lifecycle(kind: str) -> str: values are filtered separately so a non-ADR cannot inherit ``proposed``. """ reg = get_registry() - for v in reg.values(AXIS_LIFECYCLE): + for v in axis_registry_values(reg, AXIS_LIFECYCLE): if v.default and ( (kind == "adr" and "adr" in v.applies_to_kinds) or (kind != "adr" and not v.applies_to_kinds) diff --git a/mcp_server/core/wiki_redirect.py b/mcp_server/core/wiki_redirect.py index 918466a5..37852b82 100644 --- a/mcp_server/core/wiki_redirect.py +++ b/mcp_server/core/wiki_redirect.py @@ -76,10 +76,17 @@ def __post_init__(self) -> None: if self.target_id is not None and not is_valid_page_id(self.target_id): raise ValueError(f"invalid redirect_id: {self.target_id!r}; expected UUID4") - @property - def is_id_based(self) -> bool: - """True if the redirect points at a stable ID (preferred form).""" - return self.target_id is not None + +def redirect_is_id_based(redirect: "Redirect") -> bool: + """True if the redirect points at a stable ID (preferred form). + + A free function, not a method: mutmut categorically excludes the body + of any `@dataclass`-decorated class (`mutmut/mutation/file_mutation.py: + 236`), so logic placed on `Redirect` methods would carry zero mutation + coverage no matter how the test loader names the module (issue #262 + 3rd pass; issue #282). + """ + return redirect.target_id is not None def parse_redirect(frontmatter: dict[str, object]) -> Redirect | None: diff --git a/mcp_server/core/wiki_schema_loader.py b/mcp_server/core/wiki_schema_loader.py index 499f0da7..2ff65f70 100644 --- a/mcp_server/core/wiki_schema_loader.py +++ b/mcp_server/core/wiki_schema_loader.py @@ -100,9 +100,15 @@ class WikiRegistry: views: dict[str, ViewDefinition] triggers: dict[str, TriggerDefinition] - @property - def known_kind_names(self) -> set[str]: - return set(self.kinds.keys()) + +def wiki_registry_known_kind_names(registry: "WikiRegistry") -> set[str]: + """A free function, not a method: mutmut categorically excludes the + body of any `@dataclass`-decorated class (`mutmut/mutation/ + file_mutation.py:236`), so logic placed on `WikiRegistry` methods would + carry zero mutation coverage no matter how the test loader names the + module (issue #262 3rd pass; issue #282). + """ + return set(registry.kinds.keys()) # ── Parsers ─────────────────────────────────────────────────────────── diff --git a/mcp_server/core/wiki_sync.py b/mcp_server/core/wiki_sync.py index 7b6b3c06..559d1f7c 100644 --- a/mcp_server/core/wiki_sync.py +++ b/mcp_server/core/wiki_sync.py @@ -27,6 +27,7 @@ from mcp_server.core.wiki_identity import generate_page_id from mcp_server.core.wiki_layout import slugify from mcp_server.core.wiki_pages import build_note +from mcp_server.shared.wiki_classification import classification_to_frontmatter import hashlib _DECISION_TAGS = frozenset({"decision", "adr", "architecture", "spec", "design"}) @@ -118,7 +119,7 @@ def build_from_memory( # Phase 3 of ADR-2244: every page carries a stable ``id`` (UUID4) in # its frontmatter so renames can leave redirect stubs that survive # bulk migration. See ``mcp_server.core.wiki_identity``. - fm = classification.to_frontmatter() + fm = classification_to_frontmatter(classification) fm["id"] = generate_page_id() fm["title"] = title fm["updated"] = _now_iso() diff --git a/mcp_server/core/wiki_view_executor.py b/mcp_server/core/wiki_view_executor.py index 06e8df7a..bcc752d1 100644 --- a/mcp_server/core/wiki_view_executor.py +++ b/mcp_server/core/wiki_view_executor.py @@ -124,9 +124,15 @@ class CompiledView: errors: list[str] table: str - @property - def ok(self) -> bool: - return not self.errors + +def compiled_view_ok(compiled: "CompiledView") -> bool: + """A free function, not a method: mutmut categorically excludes the + body of any `@dataclass`-decorated class (`mutmut/mutation/ + file_mutation.py:236`), so logic placed on `CompiledView` methods would + carry zero mutation coverage no matter how the test loader names the + module (issue #262 3rd pass; issue #282). + """ + return not compiled.errors # ── YAML-ish parser ────────────────────────────────────────────────── @@ -362,4 +368,4 @@ def compile_view(text: str) -> CompiledView: return CompiledView(sql=sql, params=params, errors=errors, table=table) -__all__ = ["CompiledView", "compile_view"] +__all__ = ["CompiledView", "compile_view", "compiled_view_ok"] diff --git a/mcp_server/core/write_gate.py b/mcp_server/core/write_gate.py index fe21436e..dbc1f910 100644 --- a/mcp_server/core/write_gate.py +++ b/mcp_server/core/write_gate.py @@ -405,7 +405,7 @@ def apply_goal_maintenance( return novelty_score, None try: goal = read_active_goal(store) - if not goal.is_active: + if not goal_maintenance.goal_vector_is_active(goal): return novelty_score, None relevance = goal_maintenance.goal_relevance( goal, content, entities=entity_names, directory=directory @@ -419,7 +419,7 @@ def apply_goal_maintenance( return novelty_score, None modulated = max(0.0, min(1.0, novelty_score * gain)) return modulated, { - "goal": goal.as_dict(), + "goal": goal_maintenance.goal_vector_as_dict(goal), "relevance": round(relevance, 4), "gain": round(gain, 4), "modulated_novelty": round(modulated, 4), @@ -472,7 +472,9 @@ def apply_habituation( salience=salience, hours_since_salient=hours_since_salient, ) - return outcome.modulated_novelty, outcome.as_dict() + return outcome.modulated_novelty, habituation.habituation_outcome_as_dict( + outcome + ) except Exception as exc: # noqa: BLE001 — mechanism boundary — failure is observable via silent_failure ("write_gate.habituation") silent_failure.note("write_gate.habituation", exc) return novelty_score, None diff --git a/mcp_server/doctor_mcp.py b/mcp_server/doctor_mcp.py index 21925d8f..98ad87a2 100644 --- a/mcp_server/doctor_mcp.py +++ b/mcp_server/doctor_mcp.py @@ -71,27 +71,38 @@ class McpCheck: @dataclass class McpReport: - """Aggregated report from all MCP checks.""" + """Aggregated report from all MCP checks. + + Data only — deliberately no methods. mutmut's mutation generator + categorically excludes the body of any `@dataclass`-decorated class + (`mutmut/mutation/file_mutation.py:236`, confirmed empirically: issue + #262's 3rd pass, `RepoBadge` in scripts/generate_repo_badges.py), so + logic placed on methods here would carry zero mutation coverage no + matter how the test loader names the module. `mcp_report_required_fails` + / `mcp_report_warnings` / `mcp_report_to_dict` below carry the same + logic as free functions instead (issue #282). + """ checks: list[McpCheck] = field(default_factory=list) skipped: list[dict] = field(default_factory=list) # "I don't know" probes - @property - def required_fails(self) -> list[McpCheck]: - return [c for c in self.checks if not c.ok and c.severity == "fail"] - @property - def warnings(self) -> list[McpCheck]: - return [c for c in self.checks if not c.ok and c.severity == "warn"] +def mcp_report_required_fails(report: McpReport) -> list[McpCheck]: + return [c for c in report.checks if not c.ok and c.severity == "fail"] + + +def mcp_report_warnings(report: McpReport) -> list[McpCheck]: + return [c for c in report.checks if not c.ok and c.severity == "warn"] - def to_dict(self) -> dict: - return { - "checks": [asdict(c) for c in self.checks], - "skipped": list(self.skipped), - "ok": not self.required_fails, - "fail_count": len(self.required_fails), - "warn_count": len(self.warnings), - } + +def mcp_report_to_dict(report: McpReport) -> dict: + return { + "checks": [asdict(c) for c in report.checks], + "skipped": list(report.skipped), + "ok": not mcp_report_required_fails(report), + "fail_count": len(mcp_report_required_fails(report)), + "warn_count": len(mcp_report_warnings(report)), + } # --- individual checks -------------------------------------------------- @@ -718,8 +729,8 @@ def _print_human(report: McpReport, copy_header: bool = False) -> None: print(f" {marker} {skip['name']}") print(f" reason: {skip['reason']}") print("=" * 60) - fails = report.required_fails - warns = report.warnings + fails = mcp_report_required_fails(report) + warns = mcp_report_warnings(report) if not fails and not warns: print("All MCP checks passed. Cortex MCP should start cleanly.") return @@ -753,7 +764,7 @@ def run_mcp(json_output: bool = False, copy_header: bool = False) -> int: """ report = collect_mcp_report() if json_output: - print(json.dumps(report.to_dict(), indent=2)) + print(json.dumps(mcp_report_to_dict(report), indent=2)) else: _print_human(report, copy_header=copy_header) - return 0 if not report.required_fails else 1 + return 0 if not mcp_report_required_fails(report) else 1 diff --git a/mcp_server/handlers/consolidation/cycle_orchestration.py b/mcp_server/handlers/consolidation/cycle_orchestration.py index 5402948b..52fa9dcb 100644 --- a/mcp_server/handlers/consolidation/cycle_orchestration.py +++ b/mcp_server/handlers/consolidation/cycle_orchestration.py @@ -77,13 +77,12 @@ async def _invoke_anchor_call( ) # Effective per-call timeout = remaining budget time, capped at # CLAUDE_CALL_TIMEOUT_SEC and floored at 1 s. - eff_timeout = max( - 1.0, min(float(_root.CLAUDE_CALL_TIMEOUT_SEC), budget.time_left()) - ) + time_left = _root.cycle_budget_time_left(budget) + eff_timeout = max(1.0, min(float(_root.CLAUDE_CALL_TIMEOUT_SEC), time_left)) ir = await invoke( prompt, cwd=cand.source_root, source_root=cand.source_root, timeout=eff_timeout ) - budget.charge(ir.cost_usd) + _root.cycle_budget_charge(budget, ir.cost_usd) return ir, _elapsed_ms(t0) @@ -99,7 +98,7 @@ async def _drain_anchor_bounded( """Drain one anchor candidate under semaphore + budget control.""" gap = f"anchor:{cand.scope_name}" async with sem: - if budget.exhausted(): + if _root.cycle_budget_exhausted(budget): return _drain_result( cand.suggested_path, gap, "skipped", 0, "budget exhausted", _root ) @@ -133,12 +132,12 @@ def _make_charging_invoke( """Wrap ``invoke`` to auto-charge the budget and inject the effective timeout.""" async def charging_invoke(prompt: str, **kw: Any) -> Any: + time_left = _root.cycle_budget_time_left(budget) kw.setdefault( - "timeout", - max(1.0, min(float(_root.CLAUDE_CALL_TIMEOUT_SEC), budget.time_left())), + "timeout", max(1.0, min(float(_root.CLAUDE_CALL_TIMEOUT_SEC), time_left)) ) ir = await invoke(prompt, **kw) - budget.charge(ir.cost_usd) + _root.cycle_budget_charge(budget, ir.cost_usd) return ir return charging_invoke @@ -156,7 +155,7 @@ async def _drain_page_bounded( ) -> list[Any]: """Drain all gaps on one file page under semaphore + budget control.""" async with sem: - if budget.exhausted(): + if _root.cycle_budget_exhausted(budget): return [ _drain_result(page_path, "all", "skipped", 0, "budget exhausted", _root) ] diff --git a/mcp_server/handlers/consolidation/cycle_types.py b/mcp_server/handlers/consolidation/cycle_types.py index 4c583d42..0850438e 100644 --- a/mcp_server/handlers/consolidation/cycle_types.py +++ b/mcp_server/handlers/consolidation/cycle_types.py @@ -32,29 +32,41 @@ class CycleBudget: of them has charged the budget. The USD cap is therefore a SOFT ceiling with overshoot of at most ``concurrency - 1`` calls beyond the cap. This is acceptable for an operational safety rail. + + Data only — deliberately no methods. mutmut's mutation generator + categorically excludes the body of any `@dataclass`-decorated class + (`mutmut/mutation/file_mutation.py:236`), so logic placed on methods + here would carry zero mutation coverage no matter how the test loader + names the module (issue #262 3rd pass; issue #282). + `cycle_budget_time_left` / `cycle_budget_exhausted` / + `cycle_budget_charge` below carry the same logic as free functions + instead. """ deadline: float # time.monotonic() timestamp usd_cap: float # <=0 means no USD cap usd_spent: float = field(default=0.0) - def time_left(self) -> float: - """Remaining seconds until deadline (negative when expired).""" - return self.deadline - time.monotonic() - def exhausted(self) -> bool: - """True when wall-clock time is up OR the USD cap is reached.""" - if self.time_left() <= 0: - return True - return self.usd_cap > 0 and self.usd_spent >= self.usd_cap +def cycle_budget_time_left(budget: "CycleBudget") -> float: + """Remaining seconds until deadline (negative when expired).""" + return budget.deadline - time.monotonic() + + +def cycle_budget_exhausted(budget: "CycleBudget") -> bool: + """True when wall-clock time is up OR the USD cap is reached.""" + if cycle_budget_time_left(budget) <= 0: + return True + return budget.usd_cap > 0 and budget.usd_spent >= budget.usd_cap - def charge(self, usd: float) -> None: - """Add ``usd`` to the running spend. - Pre-condition: ``usd`` >= 0. - Post-condition: ``self.usd_spent`` incremented by ``usd``. - """ - self.usd_spent += usd +def cycle_budget_charge(budget: "CycleBudget", usd: float) -> None: + """Add ``usd`` to the running spend. + + Pre-condition: ``usd`` >= 0. + Post-condition: ``budget.usd_spent`` incremented by ``usd``. + """ + budget.usd_spent += usd @dataclass diff --git a/mcp_server/handlers/consolidation/distill_drain.py b/mcp_server/handlers/consolidation/distill_drain.py index d287f35f..a8b8d6ca 100644 --- a/mcp_server/handlers/consolidation/distill_drain.py +++ b/mcp_server/handlers/consolidation/distill_drain.py @@ -138,7 +138,7 @@ async def run_distill_drain_cycle( async def _drain_one(job: dict[str, Any]) -> DistillDrainResult: async with sem: - if budget.exhausted(): + if _root.cycle_budget_exhausted(budget): return DistillDrainResult( marker=job.get("marker", ""), dossier_kind=job.get("dossier_kind", ""), @@ -150,10 +150,14 @@ async def _drain_one(job: dict[str, Any]) -> DistillDrainResult: t0 = time.monotonic() prompt = str(job.get("prompt", "")) + _NO_PROMOTION_REMINDER eff_timeout = max( - 1.0, min(float(_root.CLAUDE_CALL_TIMEOUT_SEC), budget.time_left()) + 1.0, + min( + float(_root.CLAUDE_CALL_TIMEOUT_SEC), + _root.cycle_budget_time_left(budget), + ), ) ir = await invoke_fn(prompt, timeout=eff_timeout) - budget.charge(ir.cost_usd) + _root.cycle_budget_charge(budget, ir.cost_usd) ms = int((time.monotonic() - t0) * 1000) status = "attempted" if ir.text else "failed" return DistillDrainResult( diff --git a/mcp_server/handlers/consolidation/headless_authoring.py b/mcp_server/handlers/consolidation/headless_authoring.py index dae1b0c9..35429512 100644 --- a/mcp_server/handlers/consolidation/headless_authoring.py +++ b/mcp_server/handlers/consolidation/headless_authoring.py @@ -213,7 +213,14 @@ def _delegation_hint_for(kind: str) -> str | None: # definitions purely for readability (this module defines its own public # surface before re-exporting the rest of it). -from .cycle_types import CycleBudget, CycleSummary, DrainResult # noqa: E402 +from .cycle_types import ( # noqa: E402 + CycleBudget, + CycleSummary, + DrainResult, + cycle_budget_charge, + cycle_budget_exhausted, + cycle_budget_time_left, +) from .claude_cli import _build_argv, _subprocess_env # noqa: E402 from .candidate_scan import ( # noqa: E402 _collect_anchor_candidates, @@ -230,6 +237,9 @@ def _delegation_hint_for(kind: str) -> str | None: __all__ = [ "InvokeResult", "CycleBudget", + "cycle_budget_time_left", + "cycle_budget_exhausted", + "cycle_budget_charge", "DrainResult", "CycleSummary", "_AnchorCandidate", diff --git a/mcp_server/handlers/consolidation/wiki_backlog_pass.py b/mcp_server/handlers/consolidation/wiki_backlog_pass.py index 8319ff88..f6d0e817 100644 --- a/mcp_server/handlers/consolidation/wiki_backlog_pass.py +++ b/mcp_server/handlers/consolidation/wiki_backlog_pass.py @@ -36,6 +36,8 @@ _project_source_root, audit_all_domains, audit_all_file_coverage, + domain_coverage_missing_count, + file_coverage_coverage_ratio, ) from mcp_server.core.wiki_drift import audit_wiki_drift from mcp_server.infrastructure.config import WIKI_ROOT @@ -90,7 +92,7 @@ async def run_backlog_pass(store: Any) -> dict[str, Any]: ) coverages = audit_all_domains(str(WIKI_ROOT)) - out["coverage_gaps"] = sum(c.missing_count for c in coverages) + out["coverage_gaps"] = sum(domain_coverage_missing_count(c) for c in coverages) # File-level coverage: count files that aren't referenced anywhere # in the wiki. Aggregated across every domain that has a resolvable @@ -105,7 +107,7 @@ async def run_backlog_pass(store: Any) -> dict[str, Any]: "domain": r.domain, "covered": r.covered_file_count, "total": r.source_file_count, - "ratio": round(r.coverage_ratio, 3), + "ratio": round(file_coverage_coverage_ratio(r), 3), } for r in file_rolls ] diff --git a/mcp_server/handlers/curate_wiki.py b/mcp_server/handlers/curate_wiki.py index 9a9252a3..3cabbe4f 100644 --- a/mcp_server/handlers/curate_wiki.py +++ b/mcp_server/handlers/curate_wiki.py @@ -49,7 +49,14 @@ build_reauthor_jobs, sort_coverage_jobs, ) -from mcp_server.core.wiki_coverage import _project_source_root, audit_all_domains +from mcp_server.core.wiki_coverage import ( + _project_source_root, + audit_all_domains, + domain_coverage_covered_count, + domain_coverage_coverage_ratio, + domain_coverage_missing_count, + domain_coverage_missing_scopes, +) from mcp_server.core.wiki_drift import audit_wiki_drift from mcp_server.handlers._tool_meta import READ_ONLY from mcp_server.handlers.curate_wiki_serialize import ( @@ -329,10 +336,12 @@ async def handler(args: dict[str, Any] | None = None) -> dict[str, Any]: domain_coverages_summary = [ { "domain": c.domain, - "covered": c.covered_count, - "missing": c.missing_count, - "coverage_ratio": round(c.coverage_ratio, 3), - "missing_scopes": [s.scope.name for s in c.missing_scopes()], + "covered": domain_coverage_covered_count(c), + "missing": domain_coverage_missing_count(c), + "coverage_ratio": round(domain_coverage_coverage_ratio(c), 3), + "missing_scopes": [ + s.scope.name for s in domain_coverage_missing_scopes(c) + ], } for c in coverages ] diff --git a/mcp_server/handlers/ingest_document_writers.py b/mcp_server/handlers/ingest_document_writers.py index 8c3663df..52f097e7 100644 --- a/mcp_server/handlers/ingest_document_writers.py +++ b/mcp_server/handlers/ingest_document_writers.py @@ -14,7 +14,11 @@ import logging from typing import Any -from mcp_server.core.document_model import DocumentProvenance +from mcp_server.core.document_model import ( + DocumentProvenance, + document_provenance_dedup_tag, + document_provenance_source_tag, +) from mcp_server.core.document_normalizer import NormalizedDocument from mcp_server.observability import silent_failure @@ -49,7 +53,7 @@ def already_ingested(store: Any, prov: DocumentProvenance) -> int | None: (e.g. the SQLite fallback) degrades to None ("always re-ingest") rather than erroring — the same tag-query limitation ``ingest_docs_content`` already documents, not one this handler introduces.""" - tag = prov.dedup_tag() + tag = document_provenance_dedup_tag(prov) try: mems = store.get_memories_by_tag(tag, limit=5) except Exception as exc: # noqa: BLE001 — mechanism boundary; failure is observable via silent_failure @@ -66,8 +70,8 @@ def _provenance_tags(prov: DocumentProvenance, extra: list[str]) -> list[str]: "document", "ingest", prov.source_kind, - prov.dedup_tag(), - prov.source_tag(), + document_provenance_dedup_tag(prov), + document_provenance_source_tag(prov), *extra, ] diff --git a/mcp_server/handlers/procedural_skill_writer.py b/mcp_server/handlers/procedural_skill_writer.py index e322cd29..15453c5f 100644 --- a/mcp_server/handlers/procedural_skill_writer.py +++ b/mcp_server/handlers/procedural_skill_writer.py @@ -25,7 +25,7 @@ import logging from typing import Any -from mcp_server.core.procedural_memory import mine_skills +from mcp_server.core.procedural_memory import mine_skills, procedural_skill_as_dict logger = logging.getLogger(__name__) @@ -106,7 +106,7 @@ def maybe_mine_skills( skills = mine_skills(mining_input) written = 0 for skill in skills: - d = skill.as_dict() + d = procedural_skill_as_dict(skill) store.upsert_procedural_skill( { "skill_id": d["skill_id"], diff --git a/mcp_server/handlers/wiki_synthesize.py b/mcp_server/handlers/wiki_synthesize.py index d48b995d..4b867480 100644 --- a/mcp_server/handlers/wiki_synthesize.py +++ b/mcp_server/handlers/wiki_synthesize.py @@ -23,6 +23,7 @@ from typing import Any from mcp_server.core.draft_synthesizer import synthesize_draft +from mcp_server.core.wiki_schema_loader import wiki_registry_known_kind_names from mcp_server.infrastructure.wiki_schema_reader import load_registry from mcp_server.infrastructure.config import WIKI_ROOT from mcp_server.infrastructure.memory_config import get_memory_settings @@ -242,7 +243,7 @@ async def handler(args: dict[str, Any] | None = None) -> dict[str, Any]: # Load kind registry once per call registry = load_registry(Path(WIKI_ROOT)) - available_kinds = registry.known_kind_names + available_kinds = wiki_registry_known_kind_names(registry) memory_ids = _candidate_memories(conn, memory_id, force, limit) if not memory_ids: diff --git a/mcp_server/handlers/wiki_view.py b/mcp_server/handlers/wiki_view.py index 86069780..69766570 100644 --- a/mcp_server/handlers/wiki_view.py +++ b/mcp_server/handlers/wiki_view.py @@ -34,7 +34,11 @@ # // which also defers `import psycopg` to _try_pg_verbose). from mcp_server.infrastructure.wiki_schema_reader import load_registry -from mcp_server.core.wiki_view_executor import CompiledView, compile_view +from mcp_server.core.wiki_view_executor import ( + CompiledView, + compile_view, + compiled_view_ok, +) from mcp_server.infrastructure.config import WIKI_ROOT from mcp_server.infrastructure.memory_config import get_memory_settings from mcp_server.infrastructure.memory_store import MemoryStore, get_shared_store @@ -225,7 +229,7 @@ async def handler(args: dict[str, Any] | None = None) -> dict[str, Any]: } compiled = compile_view(query_text) - if not compiled.ok: + if not compiled_view_ok(compiled): return { "view": view_meta, "error": "compile failed", diff --git a/mcp_server/shared/wiki_classification.py b/mcp_server/shared/wiki_classification.py index 6dc46f22..1b5f02a7 100644 --- a/mcp_server/shared/wiki_classification.py +++ b/mcp_server/shared/wiki_classification.py @@ -95,91 +95,133 @@ class Classification: tags: tuple[str, ...] = () def __post_init__(self) -> None: - self.validate() - - def validate(self) -> None: - """Raise ValueError (with did-you-mean) if any axis violates the schema.""" - # Local import avoids importing the registry at module-load time - # (the registry reads the wiki on first call). - from mcp_server.core.wiki_axis_registry import ( # noqa: PLC0415 — documented deferral: the registry reads the wiki on first call; a module-load import would also invert the shared->core layer rule at import time - AXIS_AUDIENCE, - AXIS_KIND, - AXIS_LIFECYCLE, - AXIS_PROVENANCE, - did_you_mean, - get_registry, + # __post_init__ is a dunder — mutmut skips the WHOLE decorated + # ClassDef body regardless (`mutmut/mutation/file_mutation.py:236`), + # so this call site carries no mutation coverage either way; kept + # as a method (constructors need one) and calls the free function + # below for the actual validation logic (issue #282). + validate_classification(self) + + +def validate_classification(classification: "Classification") -> None: + """Raise ValueError (with did-you-mean) if any axis violates the schema. + + A free function, not a method: mutmut categorically excludes the body + of any `@dataclass`-decorated class (`mutmut/mutation/file_mutation.py: + 236`), so logic placed on `Classification` methods would carry zero + mutation coverage no matter how the test loader names the module + (issue #262 3rd pass; issue #282). Split per-axis (§4.2, 40-line cap) + into `_validate_kind` / `_validate_lifecycle` / `_validate_audience` / + `_validate_provenance` below — the pre-extraction `validate()` method + was already 64 lines as a single block; this keeps the orchestrator + short instead of just relocating the same oversized function. + """ + # Local import avoids importing the registry at module-load time + # (the registry reads the wiki on first call). + from mcp_server.core.wiki_axis_registry import ( # noqa: PLC0415 — documented deferral: the registry reads the wiki on first call; a module-load import would also invert the shared->core layer rule at import time + get_registry, + ) + + reg = get_registry() + c = classification + _validate_kind(reg, c) + _validate_lifecycle(reg, c) + _validate_audience(reg, c) + _validate_provenance(reg, c) + + +def _validate_kind(reg, c: "Classification") -> None: + from mcp_server.core.wiki_axis_registry import ( # noqa: PLC0415 — see validate_classification + AXIS_KIND, + axis_registry_has, + did_you_mean, + ) + + if not axis_registry_has(reg, AXIS_KIND, c.kind): + suggestions = did_you_mean(AXIS_KIND, c.kind, reg) + raise ValueError(_format_unknown(AXIS_KIND, c.kind, suggestions)) + + +def _validate_lifecycle(reg, c: "Classification") -> None: + from mcp_server.core.wiki_axis_registry import ( # noqa: PLC0415 — see validate_classification + AXIS_LIFECYCLE, + axis_registry_get, + axis_registry_values, + did_you_mean, + ) + + lc = axis_registry_get(reg, AXIS_LIFECYCLE, c.lifecycle) + if lc is None: + suggestions = did_you_mean(AXIS_LIFECYCLE, c.lifecycle, reg) + raise ValueError(_format_unknown(AXIS_LIFECYCLE, c.lifecycle, suggestions)) + if lc.applies_to_kinds and c.kind not in lc.applies_to_kinds: + raise ValueError( + f"lifecycle {c.lifecycle!r} does not apply to kind " + f"{c.kind!r} (only to {sorted(lc.applies_to_kinds)})" + ) + if not lc.applies_to_kinds and c.kind == "adr": + # ADRs must use the kind-specific subset. + adr_lc = [ + v.name + for v in axis_registry_values(reg, AXIS_LIFECYCLE) + if "adr" in v.applies_to_kinds + ] + raise ValueError( + f"kind=adr requires a lifecycle from {sorted(adr_lc)}; got {c.lifecycle!r}" ) - reg = get_registry() - - # Kind ─────────────────────────────────────────────────────── - if not reg.has(AXIS_KIND, self.kind): - suggestions = did_you_mean(AXIS_KIND, self.kind, reg) - raise ValueError(_format_unknown(AXIS_KIND, self.kind, suggestions)) - - # Lifecycle ────────────────────────────────────────────────── - # Lifecycle value must exist and must apply to this kind. - lc = reg.get(AXIS_LIFECYCLE, self.lifecycle) - if lc is None: - suggestions = did_you_mean(AXIS_LIFECYCLE, self.lifecycle, reg) - raise ValueError( - _format_unknown(AXIS_LIFECYCLE, self.lifecycle, suggestions) - ) - if lc.applies_to_kinds and self.kind not in lc.applies_to_kinds: - raise ValueError( - f"lifecycle {self.lifecycle!r} does not apply to kind " - f"{self.kind!r} (only to {sorted(lc.applies_to_kinds)})" - ) - if not lc.applies_to_kinds and self.kind == "adr": - # ADRs must use the kind-specific subset. - adr_lc = [ - v.name - for v in reg.values(AXIS_LIFECYCLE) - if "adr" in v.applies_to_kinds - ] - raise ValueError( - f"kind=adr requires a lifecycle from {sorted(adr_lc)}; " - f"got {self.lifecycle!r}" - ) - - # Audience ─────────────────────────────────────────────────── - if not self.audience: - raise ValueError("audience must not be empty") - for a in self.audience: - if not reg.has(AXIS_AUDIENCE, a): - suggestions = did_you_mean(AXIS_AUDIENCE, a, reg) - raise ValueError(_format_unknown(AXIS_AUDIENCE, a, suggestions)) - - # Provenance ───────────────────────────────────────────────── - prov = reg.get(AXIS_PROVENANCE, self.provenance) - if prov is None: - suggestions = did_you_mean(AXIS_PROVENANCE, self.provenance, reg) - raise ValueError( - _format_unknown(AXIS_PROVENANCE, self.provenance, suggestions) - ) - if prov.requires_generator and self.generator is None: - raise ValueError( - f"provenance={self.provenance!r} requires a Generator block" - ) - - def to_frontmatter(self) -> dict[str, object]: - """Render this classification as a YAML-compatible frontmatter dict.""" - fm: dict[str, object] = { - "kind": self.kind, - "lifecycle": self.lifecycle, - "audience": list(self.audience), - "provenance": self.provenance, + +def _validate_audience(reg, c: "Classification") -> None: + from mcp_server.core.wiki_axis_registry import ( # noqa: PLC0415 — see validate_classification + AXIS_AUDIENCE, + axis_registry_has, + did_you_mean, + ) + + if not c.audience: + raise ValueError("audience must not be empty") + for a in c.audience: + if not axis_registry_has(reg, AXIS_AUDIENCE, a): + suggestions = did_you_mean(AXIS_AUDIENCE, a, reg) + raise ValueError(_format_unknown(AXIS_AUDIENCE, a, suggestions)) + + +def _validate_provenance(reg, c: "Classification") -> None: + from mcp_server.core.wiki_axis_registry import ( # noqa: PLC0415 — see validate_classification + AXIS_PROVENANCE, + axis_registry_get, + did_you_mean, + ) + + prov = axis_registry_get(reg, AXIS_PROVENANCE, c.provenance) + if prov is None: + suggestions = did_you_mean(AXIS_PROVENANCE, c.provenance, reg) + raise ValueError(_format_unknown(AXIS_PROVENANCE, c.provenance, suggestions)) + if prov.requires_generator and c.generator is None: + raise ValueError(f"provenance={c.provenance!r} requires a Generator block") + + +def classification_to_frontmatter( + classification: "Classification", +) -> dict[str, object]: + """Render a classification as a YAML-compatible frontmatter dict.""" + c = classification + fm: dict[str, object] = { + "kind": c.kind, + "lifecycle": c.lifecycle, + "audience": list(c.audience), + "provenance": c.provenance, + } + if c.generator is not None: + fm["generator"] = { + "model": c.generator.model, + "version": c.generator.version, + "prompt_template": c.generator.prompt_template, + "generated_at": c.generator.generated_at, } - if self.generator is not None: - fm["generator"] = { - "model": self.generator.model, - "version": self.generator.version, - "prompt_template": self.generator.prompt_template, - "generated_at": self.generator.generated_at, - } - if self.tags: - fm["tags"] = list(self.tags) - return fm + if c.tags: + fm["tags"] = list(c.tags) + return fm def _format_unknown(axis: str, value: str, suggestions: tuple[str, ...]) -> str: @@ -212,6 +254,10 @@ def is_legacy_kind(kind: str) -> bool: def all_known_kinds() -> frozenset[str]: """Modern (registered) + legacy kinds. For read paths that must accept either.""" - from mcp_server.core.wiki_axis_registry import AXIS_KIND, get_registry # noqa: PLC0415 — documented deferral: the registry reads the wiki on first call; a module-load import would also invert the shared->core layer rule at import time + from mcp_server.core.wiki_axis_registry import ( # noqa: PLC0415 — documented deferral: the registry reads the wiki on first call; a module-load import would also invert the shared->core layer rule at import time + AXIS_KIND, + axis_registry_names, + get_registry, + ) - return frozenset(get_registry().names(AXIS_KIND)) | LEGACY_KINDS + return frozenset(axis_registry_names(get_registry(), AXIS_KIND)) | LEGACY_KINDS diff --git a/tests_py/benchmarks/test_lib_init_no_psycopg.py b/tests_py/benchmarks/test_lib_init_no_psycopg.py new file mode 100644 index 00000000..6acf3970 --- /dev/null +++ b/tests_py/benchmarks/test_lib_init_no_psycopg.py @@ -0,0 +1,110 @@ +"""Regression test: importing `benchmarks.lib.*` must not require psycopg. + +`benchmarks/lib/__init__.py` used to eagerly `import BenchmarkDB` at +top level, which transitively hard-imports `psycopg`/`psycopg_pool`/ +`pgvector` (`benchmarks/lib/bench_db.py` -> `mcp_server/infrastructure/ +pg_store.py`). Because Python always runs a package's `__init__.py` +before any of its submodules, that made every submodule of +`benchmarks.lib` — including the Postgres-independent +`verification_report.py` — unimportable on an install without the +Postgres extras (reproduced on CI's SQLite-backend job, PR closing +issue #282: `ModuleNotFoundError: No module named 'psycopg'` raised +from collecting `tests_py/benchmarks/test_verification_report.py`, +which imports nothing PG-related itself). Every other consumer of +`bench_db.BenchmarkDB` in this package already defers that import +inside a function for exactly this reason (see the "deferred: module +hard-imports pgvector/psycopg/psycopg_pool at top level" comments in +`ablation_runner.py`, `longitudinal_runner.py`, `_xb_drivers.py`, +`llm_head_to_head/pilot.py`); `__init__.py` did not follow its own +package's convention. Fixed via a PEP 562 module `__getattr__` that +resolves `BenchmarkDB` lazily. + +This test spawns a real subprocess with `psycopg`/`psycopg_pool`/ +`pgvector` poisoned in `sys.modules` (`None`, the standard way to force +`ImportError` on a specific module) — a real subprocess so poisoning +sys.modules cannot leak into the shared pytest session. +""" + +from __future__ import annotations + +import subprocess +import sys + +import pytest + +_POISON = ( + "import sys; " + "sys.modules['psycopg'] = None; " + "sys.modules['psycopg_pool'] = None; " + "sys.modules['pgvector'] = None; " + "sys.modules['pgvector.psycopg'] = None; " +) + + +def _run(snippet: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-c", _POISON + snippet], + capture_output=True, + text=True, + timeout=30, + ) + + +def test_verification_report_importable_without_psycopg(): + """The bug this test pins: importing a PG-independent submodule of + `benchmarks.lib` must not require psycopg.""" + result = _run( + "import benchmarks.lib.verification_report as vr; " + "assert vr.exp_result_name is not None; " + "print('OK')" + ) + assert result.returncode == 0, result.stderr + assert "OK" in result.stdout + + +def test_benchmark_db_still_reachable_lazily_and_fails_loudly_without_psycopg(): + """`BenchmarkDB` stays reachable via the package path, but resolving + it without psycopg installed raises promptly and legibly — never a + silent no-op, never blocking an unrelated submodule's import.""" + result = _run( + "import benchmarks.lib as lib; " + "assert 'BenchmarkDB' in lib.__all__; " + "lib.BenchmarkDB" + ) + assert result.returncode != 0 + assert "psycopg" in result.stderr + + +def test_benchmark_db_resolves_with_psycopg_present(): + """Sanity check the lazy path is not just erroring on everything: + without poisoning sys.modules (real psycopg present in this venv), + `benchmarks.lib.BenchmarkDB` resolves to the real class. + + Skipped (not failed) on an install genuinely without the Postgres + extras (e.g. CI's SQLite-backend job, which explicitly installs "no + postgresql extra") -- there psycopg's absence is the environment, not + a regression, and `test_benchmark_db_still_reachable_lazily_and_fails_ + loudly_without_psycopg` above already pins that exact behavior. + """ + pytest.importorskip("psycopg") + result = subprocess.run( + [ + sys.executable, + "-c", + "import benchmarks.lib as lib; " + "from benchmarks.lib.bench_db import BenchmarkDB; " + "assert lib.BenchmarkDB is BenchmarkDB; " + "print('OK')", + ], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr + assert "OK" in result.stdout + + +def test_unknown_attribute_still_raises_attribute_error(): + result = _run("import benchmarks.lib as lib; lib.NotARealSymbol") + assert result.returncode != 0 + assert "AttributeError" in result.stderr diff --git a/tests_py/benchmarks/test_verification_report.py b/tests_py/benchmarks/test_verification_report.py new file mode 100644 index 00000000..597df1ee --- /dev/null +++ b/tests_py/benchmarks/test_verification_report.py @@ -0,0 +1,112 @@ +"""Direct unit tests for `benchmarks.lib.verification_report`'s ExpResult +free functions (issue #282: dataclass mutation blindspot). + +`ExpResult` is a plain `@dataclass`; mutmut categorically skips the body of +any `@dataclass`-decorated class (`mutmut/mutation/file_mutation.py:236`), +so the lookup/verdict logic that used to live on `ExpResult.name` / +`.threshold` / `.verdict` carries zero mutation coverage unless exercised +directly against the extracted free functions. No test module for +`verification_report.py` existed prior to this file. +""" + +from __future__ import annotations + +from benchmarks.lib.verification_report import ( + HYPOTHESES, + ExpResult, + exp_result_name, + exp_result_threshold, + exp_result_verdict, +) + + +def test_exp_result_name_looks_up_hypotheses_table(): + r = ExpResult(exp_id="E1") + assert exp_result_name(r) == HYPOTHESES["E1"]["name"] + + +def test_exp_result_threshold_looks_up_hypotheses_table(): + r = ExpResult(exp_id="E3") + assert exp_result_threshold(r) == HYPOTHESES["E3"]["threshold"] + + +def test_verdict_inconclusive_when_not_found(): + r = ExpResult(exp_id="E1", found=False) + assert exp_result_verdict(r) == "INCONCLUSIVE" + + +def test_verdict_inconclusive_when_found_but_no_observed_value(): + r = ExpResult(exp_id="E1", found=True, observed=None) + assert exp_result_verdict(r) == "INCONCLUSIVE" + + +def test_verdict_pass_for_ge_operator(): + # E1's pass_op is ">=", pass_value 0.01. + r = ExpResult(exp_id="E1", found=True, observed=0.01) + assert exp_result_verdict(r) == "PASS" + + +def test_verdict_fail_for_ge_operator_below_threshold(): + r = ExpResult(exp_id="E1", found=True, observed=0.001) + assert exp_result_verdict(r) == "FAIL" + + +def test_verdict_pass_for_lt_operator(): + # E3's pass_op is "<", pass_value 0.05. + r = ExpResult(exp_id="E3", found=True, observed=0.049) + assert exp_result_verdict(r) == "PASS" + + +def test_verdict_fail_for_lt_operator_at_boundary(): + r = ExpResult(exp_id="E3", found=True, observed=0.05) + assert exp_result_verdict(r) == "FAIL" + + +# ── The ">" / "<=" branches + the unrecognized-op fallback (issue #282: +# no HYPOTHESES entry currently uses ">" or "<=" as pass_op, so those two +# `cmp` dict branches and the `cmp.get(op, False)` default value are +# unreachable through the real table alone — a monkeypatched HYPOTHESES +# entry exercises them directly rather than leaving them permanently +# invisible to mutation testing). ────────────────────────────────────── +def test_verdict_gt_operator_pass_and_fail(monkeypatch): + monkeypatch.setitem( + HYPOTHESES, + "ZZ_GT", + {"name": "n", "threshold": "t", "pass_op": ">", "pass_value": 1.0}, + ) + assert ( + exp_result_verdict(ExpResult(exp_id="ZZ_GT", found=True, observed=1.5)) + == "PASS" + ) + assert ( + exp_result_verdict(ExpResult(exp_id="ZZ_GT", found=True, observed=1.0)) + == "FAIL" + ) + + +def test_verdict_le_operator_pass_and_fail(monkeypatch): + monkeypatch.setitem( + HYPOTHESES, + "ZZ_LE", + {"name": "n", "threshold": "t", "pass_op": "<=", "pass_value": 1.0}, + ) + assert ( + exp_result_verdict(ExpResult(exp_id="ZZ_LE", found=True, observed=1.0)) + == "PASS" + ) + assert ( + exp_result_verdict(ExpResult(exp_id="ZZ_LE", found=True, observed=1.5)) + == "FAIL" + ) + + +def test_verdict_unrecognized_operator_defaults_to_fail(monkeypatch): + monkeypatch.setitem( + HYPOTHESES, + "ZZ_BAD", + {"name": "n", "threshold": "t", "pass_op": "!=", "pass_value": 1.0}, + ) + assert ( + exp_result_verdict(ExpResult(exp_id="ZZ_BAD", found=True, observed=99.0)) + == "FAIL" + ) diff --git a/tests_py/core/context_assembly/test_decomposer.py b/tests_py/core/context_assembly/test_decomposer.py index 859aeb20..c40d2273 100644 --- a/tests_py/core/context_assembly/test_decomposer.py +++ b/tests_py/core/context_assembly/test_decomposer.py @@ -9,6 +9,8 @@ from mcp_server.core.context_assembly.budget import ( AssemblyMetrics, Placeholder, + assembly_metrics_reduction_fraction, + assembly_metrics_was_truncated, available_budget, estimate_tokens, truncate_to_budget, @@ -19,25 +21,46 @@ class TestAssemblyMetrics: def test_reduction_fraction_zero_original_is_full(self): metrics = AssemblyMetrics() - assert metrics.reduction_fraction("{{A}}") == 1.0 + assert assembly_metrics_reduction_fraction(metrics, "{{A}}") == 1.0 def test_reduction_fraction_computes_ratio(self): metrics = AssemblyMetrics( original_tokens={"{{A}}": 100}, final_tokens={"{{A}}": 50} ) - assert metrics.reduction_fraction("{{A}}") == 0.5 + assert assembly_metrics_reduction_fraction(metrics, "{{A}}") == 0.5 def test_was_truncated_true_below_threshold(self): metrics = AssemblyMetrics( original_tokens={"{{A}}": 100}, final_tokens={"{{A}}": 50} ) - assert metrics.was_truncated("{{A}}") is True + assert assembly_metrics_was_truncated(metrics, "{{A}}") is True def test_was_truncated_false_above_threshold(self): metrics = AssemblyMetrics( original_tokens={"{{A}}": 100}, final_tokens={"{{A}}": 95} ) - assert metrics.was_truncated("{{A}}") is False + assert assembly_metrics_was_truncated(metrics, "{{A}}") is False + + def test_reduction_fraction_key_absent_from_final_tokens_is_zero(self): + # original_tokens carries the key (non-zero, so the vacuous 1.0 + # short-circuit doesn't fire) but final_tokens never got an entry + # for it — the real function's `.get(key, 0)` default reads this + # as "fully condensed away" (ratio 0.0); a mutant that defaults to + # `None` would raise TypeError on the division instead, and one + # that defaults to `1` would silently read 1/100 = 0.01 (issue + # #282 mutation-testing gap: every other fixture here has the key + # present in BOTH dicts, so the default value is never reached). + metrics = AssemblyMetrics(original_tokens={"{{A}}": 100}, final_tokens={}) + assert assembly_metrics_reduction_fraction(metrics, "{{A}}") == 0.0 + + def test_was_truncated_boundary_is_strictly_below_not_at_threshold(self): + # ratio == threshold exactly (90/100 = 0.9, the default threshold): + # `<` is False (not truncated — meeting the bar counts as fine); + # `<=` would flip this to True (issue #282 mutation-testing gap). + metrics = AssemblyMetrics( + original_tokens={"{{A}}": 100}, final_tokens={"{{A}}": 90} + ) + assert assembly_metrics_was_truncated(metrics, "{{A}}") is False class TestEstimateTokens: diff --git a/tests_py/core/streaming/test_adaptive_controller.py b/tests_py/core/streaming/test_adaptive_controller.py index 696463dd..40413568 100644 --- a/tests_py/core/streaming/test_adaptive_controller.py +++ b/tests_py/core/streaming/test_adaptive_controller.py @@ -2,13 +2,17 @@ import pytest -from mcp_server.core.streaming.adaptive_controller import AdaptiveBatchController +from mcp_server.core.streaming.adaptive_controller import ( + AdaptiveBatchController, + adaptive_batch_controller_batch_size, + adaptive_batch_controller_observe, +) class TestConstruction: def test_starts_at_b_min(self): c = AdaptiveBatchController(b_min=100, b_max=10000, w_target_s=0.5) - assert c.batch_size == 100 + assert adaptive_batch_controller_batch_size(c) == 100 def test_ai_step_defaults_to_b_min(self): c = AdaptiveBatchController(b_min=100, b_max=10000, w_target_s=0.5) @@ -31,29 +35,37 @@ def test_rejects_nonpositive_target(self): class TestAdditiveIncrease: def test_within_target_grows_by_ai_step(self): c = AdaptiveBatchController(b_min=100, b_max=10000, w_target_s=0.5, ai_step=200) - assert c.observe(0.1) == 300 - assert c.observe(0.1) == 500 + assert adaptive_batch_controller_observe(c, 0.1) == 300 + assert adaptive_batch_controller_observe(c, 0.1) == 500 def test_increase_clamps_at_b_max(self): c = AdaptiveBatchController(b_min=100, b_max=250, w_target_s=0.5, ai_step=200) - assert c.observe(0.1) == 250 # 100 + 200 = 300 -> clamped - assert c.observe(0.1) == 250 # stays clamped + assert ( + adaptive_batch_controller_observe(c, 0.1) == 250 + ) # 100 + 200 = 300 -> clamped + assert adaptive_batch_controller_observe(c, 0.1) == 250 # stays clamped def test_latency_equal_to_target_is_within(self): c = AdaptiveBatchController(b_min=100, b_max=10000, w_target_s=0.5, ai_step=100) - assert c.observe(0.5) == 200 # <= target counts as within + assert ( + adaptive_batch_controller_observe(c, 0.5) == 200 + ) # <= target counts as within class TestMultiplicativeDecrease: def test_over_target_halves(self): c = AdaptiveBatchController(b_min=10, b_max=10000, w_target_s=0.5, ai_step=100) - c.observe(0.1) # 10 -> 110 - c.observe(0.1) # 110 -> 210 - assert c.observe(1.0) == 105 # over target -> floor(210 * 0.5) + adaptive_batch_controller_observe(c, 0.1) # 10 -> 110 + adaptive_batch_controller_observe(c, 0.1) # 110 -> 210 + assert ( + adaptive_batch_controller_observe(c, 1.0) == 105 + ) # over target -> floor(210 * 0.5) def test_decrease_clamps_at_b_min(self): c = AdaptiveBatchController(b_min=100, b_max=10000, w_target_s=0.5) - assert c.observe(5.0) == 100 # floor(100*0.5)=50 -> clamped to b_min + assert ( + adaptive_batch_controller_observe(c, 5.0) == 100 + ) # floor(100*0.5)=50 -> clamped to b_min class TestConvergence: @@ -68,8 +80,8 @@ def test_oscillates_around_a_stable_band(self): c = AdaptiveBatchController(b_min=100, b_max=10000, w_target_s=0.5, ai_step=100) seen = [] for _ in range(500): - latency = 0.1 if c.batch_size <= knee else 1.0 - seen.append(c.observe(latency)) + latency = 0.1 if adaptive_batch_controller_batch_size(c) <= knee else 1.0 + seen.append(adaptive_batch_controller_observe(c, latency)) # Never escapes the hard bounds (invariant precond 4). assert all(100 <= b <= 10000 for b in seen) # Settles near the knee, never near b_max runaway. diff --git a/tests_py/core/streaming/test_backpressure_pipeline.py b/tests_py/core/streaming/test_backpressure_pipeline.py index e3d69464..27895793 100644 --- a/tests_py/core/streaming/test_backpressure_pipeline.py +++ b/tests_py/core/streaming/test_backpressure_pipeline.py @@ -13,6 +13,7 @@ from mcp_server.core.streaming.backpressure_pipeline import ( BackpressurePipeline, + backpressure_pipeline_run, compute_queue_cap, ) @@ -84,7 +85,7 @@ def factory(): pipe = BackpressurePipeline( source=src, sink_factory=factory, max_batch=50, queue_cap=4, concurrency=3 ) - result = pipe.run() + result = backpressure_pipeline_run(pipe) assert result.errors == [] assert result.rows_in == 500 assert result.rows_written == 500 @@ -103,7 +104,7 @@ def test_one_worker_still_correct(self): queue_cap=2, concurrency=1, ) - result = pipe.run() + result = backpressure_pipeline_run(pipe) assert result.rows_written == 100 assert result.errors == [] @@ -120,7 +121,7 @@ def test_small_queue_slow_consumer_no_loss(self): queue_cap=1, concurrency=2, ) - result = pipe.run() + result = backpressure_pipeline_run(pipe) assert result.rows_written == 200 assert result.errors == [] @@ -139,7 +140,7 @@ def stream(self, max_batch): queue_cap=2, concurrency=3, ) - result = pipe.run() # must return, not hang + result = backpressure_pipeline_run(pipe) # must return, not hang assert any("producer" in e and "kuzu boom" in e for e in result.errors) def test_sink_setup_failure_no_hang(self): @@ -153,7 +154,7 @@ def bad_factory(): queue_cap=2, concurrency=2, ) - result = pipe.run() # must return despite no usable sinks + result = backpressure_pipeline_run(pipe) # must return despite no usable sinks assert any("worker-setup" in e for e in result.errors) assert len([e for e in result.errors if "worker-setup" in e]) == 2 @@ -171,6 +172,27 @@ def write_batch(self, batch): queue_cap=8, concurrency=1, ) - result = pipe.run() + result = backpressure_pipeline_run(pipe) assert any("worker:" in e for e in result.errors) assert result.rows_written == 30 # 3 of 4 batches survived + + def test_close_failure_is_reported_not_swallowed(self): + # issue #282 mutation-testing gap: no prior test made `.close()` + # itself raise, so `_bp_close`'s error-reporting path (append to + # result.errors under the lock) never ran — a mutant that appends + # `None` instead of the formatted message, or drops the lock/result + # arg entirely, was invisible. + class CloseFailsSink(RecordingSink): + def close(self): + raise RuntimeError("disk full") + + pipe = BackpressurePipeline( + source=FakeSource(2, 10), + sink_factory=CloseFailsSink, + max_batch=10, + queue_cap=4, + concurrency=1, + ) + result = backpressure_pipeline_run(pipe) + assert result.rows_written == 20 # writes still succeeded + assert any("worker-close" in e and "disk full" in e for e in result.errors) diff --git a/tests_py/core/test_ablation.py b/tests_py/core/test_ablation.py index ac39ea0a..56d2f38d 100644 --- a/tests_py/core/test_ablation.py +++ b/tests_py/core/test_ablation.py @@ -4,6 +4,10 @@ from mcp_server.core.ablation import ( Mechanism, AblationConfig, + ablation_config_disable, + ablation_config_disable_all_except, + ablation_config_enable, + ablation_config_is_enabled, compute_ablation_deltas, compute_impact_score, generate_interpretation, @@ -21,29 +25,30 @@ class TestAblationConfig: def test_all_enabled_by_default(self): config = AblationConfig() for m in Mechanism: - assert config.is_enabled(m) + assert ablation_config_is_enabled(config, m) def test_disable_one(self): - config = AblationConfig().disable(Mechanism.OSCILLATORY_CLOCK) - assert not config.is_enabled(Mechanism.OSCILLATORY_CLOCK) - assert config.is_enabled(Mechanism.SCHEMA_ENGINE) + config = ablation_config_disable(AblationConfig(), Mechanism.OSCILLATORY_CLOCK) + assert not ablation_config_is_enabled(config, Mechanism.OSCILLATORY_CLOCK) + assert ablation_config_is_enabled(config, Mechanism.SCHEMA_ENGINE) def test_enable_after_disable(self): - config = ( - AblationConfig() - .disable(Mechanism.SCHEMA_ENGINE) - .enable(Mechanism.SCHEMA_ENGINE) + config = ablation_config_enable( + ablation_config_disable(AblationConfig(), Mechanism.SCHEMA_ENGINE), + Mechanism.SCHEMA_ENGINE, ) - assert config.is_enabled(Mechanism.SCHEMA_ENGINE) + assert ablation_config_is_enabled(config, Mechanism.SCHEMA_ENGINE) def test_disable_all_except(self): - config = AblationConfig().disable_all_except(Mechanism.OSCILLATORY_CLOCK) - assert config.is_enabled(Mechanism.OSCILLATORY_CLOCK) - assert not config.is_enabled(Mechanism.SCHEMA_ENGINE) + config = ablation_config_disable_all_except( + AblationConfig(), Mechanism.OSCILLATORY_CLOCK + ) + assert ablation_config_is_enabled(config, Mechanism.OSCILLATORY_CLOCK) + assert not ablation_config_is_enabled(config, Mechanism.SCHEMA_ENGINE) def test_string_key_works(self): - config = AblationConfig().disable("oscillatory_clock") - assert not config.is_enabled("oscillatory_clock") + config = ablation_config_disable(AblationConfig(), "oscillatory_clock") + assert not ablation_config_is_enabled(config, "oscillatory_clock") class TestDeltas: diff --git a/tests_py/core/test_attentional_control.py b/tests_py/core/test_attentional_control.py index 0b647e17..0ebabd13 100644 --- a/tests_py/core/test_attentional_control.py +++ b/tests_py/core/test_attentional_control.py @@ -10,6 +10,7 @@ ATTENTION_TEMPERATURE, FOCUS_CAPACITY_DEFAULT, allocate_attention, + attention_allocation_as_dict, in_focus_contents, relevance_score, ) @@ -125,3 +126,16 @@ def test_in_focus_contents_order(): def test_defaults_sane(): assert FOCUS_CAPACITY_DEFAULT == 4 assert 0.0 < ATTENTION_TEMPERATURE <= 1.0 + + +# ── attention_allocation_as_dict (issue #282: dataclass mutation blindspot) ── +def test_attention_allocation_as_dict_shape_and_rounding(): + items = _items("low", "decay high match", "mid decay") + a = allocate_attention("decay high match", items) + d = attention_allocation_as_dict(a) + assert set(d) == {"focus", "weights", "entropy", "capacity", "overflow"} + assert d["focus"] == a.focus + assert d["weights"] == [round(w, 4) for w in a.weights] + assert d["entropy"] == round(a.entropy, 4) + assert d["capacity"] == a.capacity + assert d["overflow"] == a.overflow diff --git a/tests_py/core/test_conflict_monitor.py b/tests_py/core/test_conflict_monitor.py index fc3260e1..b5850c9f 100644 --- a/tests_py/core/test_conflict_monitor.py +++ b/tests_py/core/test_conflict_monitor.py @@ -224,3 +224,39 @@ def test_route_typed_claims_detects_conflict(): pair = plans[0] assert {pair.claim_a_id, pair.claim_b_id} == {10, 11} assert 42 in pair.overlap_entities + + +# ── conflict_assessment_as_dict (issue #282: dataclass mutation blindspot) ──── +def test_conflict_assessment_as_dict_full_shape_and_rounding(): + assessment = cm.ConflictAssessment( + conflict_score=0.512345, + entropy=0.698765, + max_contradiction=0.887654, + competing_pair=(10, 11), + loser_id=11, + high=True, + ) + d = cm.conflict_assessment_as_dict(assessment) + assert d == { + "conflict_score": round(0.512345, 4), + "entropy": round(0.698765, 4), + "max_contradiction": round(0.887654, 4), + "competing_pair": [10, 11], + "loser_id": 11, + "high": True, + } + + +def test_conflict_assessment_as_dict_none_competing_pair_stays_none(): + assessment = cm.ConflictAssessment( + conflict_score=0.0, + entropy=0.0, + max_contradiction=0.0, + competing_pair=None, + loser_id=None, + high=False, + ) + d = cm.conflict_assessment_as_dict(assessment) + assert d["competing_pair"] is None + assert d["loser_id"] is None + assert d["high"] is False diff --git a/tests_py/core/test_confluence_parser.py b/tests_py/core/test_confluence_parser.py index afa6060d..521f75ce 100644 --- a/tests_py/core/test_confluence_parser.py +++ b/tests_py/core/test_confluence_parser.py @@ -5,7 +5,11 @@ import pytest from mcp_server.core.confluence_parser import parse_confluence_storage -from mcp_server.core.document_model import DocumentParseError +from mcp_server.core.document_model import ( + DocumentParseError, + document_section_is_empty, + parsed_document_is_empty, +) class TestHeadingsAndBody: @@ -111,11 +115,11 @@ def test_table_extracted(self): class TestEdgeCases: def test_empty_is_empty(self): doc = parse_confluence_storage("") - assert doc.is_empty() + assert parsed_document_is_empty(doc) def test_headings_only(self): doc = parse_confluence_storage("

A

B

") - assert all(s.is_empty() for s in doc.sections) + assert all(document_section_is_empty(s) for s in doc.sections) def test_malformed_xhtml_raises_with_message(self): with pytest.raises(DocumentParseError, match="malformed Confluence XHTML"): diff --git a/tests_py/core/test_docx_parser.py b/tests_py/core/test_docx_parser.py index eea2fad0..5717eec6 100644 --- a/tests_py/core/test_docx_parser.py +++ b/tests_py/core/test_docx_parser.py @@ -5,7 +5,11 @@ import pytest from mcp_server.core.docx_parser import parse_docx_xml -from mcp_server.core.document_model import DocumentParseError +from mcp_server.core.document_model import ( + DocumentParseError, + document_section_is_empty, + parsed_document_is_empty, +) _W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" @@ -155,11 +159,11 @@ def test_no_images_counts_zero(self): class TestEdgeCases: def test_empty_document_is_empty_not_error(self): doc = parse_docx_xml(_doc("")) - assert doc.is_empty() + assert parsed_document_is_empty(doc) def test_headings_only_document(self): doc = parse_docx_xml(_doc(_para("A", "Heading1") + _para("B", "Heading1"))) - assert all(s.is_empty() for s in doc.sections) + assert all(document_section_is_empty(s) for s in doc.sections) assert [s.heading for s in doc.sections] == ["A", "B"] def test_malformed_xml_raises_with_message(self): diff --git a/tests_py/core/test_dual_process_retrieval.py b/tests_py/core/test_dual_process_retrieval.py index eb8d7fc7..7e2a1bf0 100644 --- a/tests_py/core/test_dual_process_retrieval.py +++ b/tests_py/core/test_dual_process_retrieval.py @@ -21,6 +21,7 @@ METHOD_VECTOR, assess_familiarity, familiarity_score, + familiarity_signal_as_dict, recollection_needed, triage, ) @@ -153,3 +154,18 @@ def test_triage_empty(): assert res.candidates == [] assert res.recollection_needed is True assert res.shortcut is False + + +# ── familiarity_signal_as_dict (issue #282: dataclass mutation blindspot) ──── +def test_familiarity_signal_as_dict_rounds_and_shapes(): + # 7th-decimal precision on every similarity so round(x, 6) vs round(x, 7) + # is observable for familiarity/mean/margin alike (issue #282 gap: a + # 1-2 significant-digit fixture rounds identically at 6 vs 7 places). + signal = assess_familiarity([0.9123456, 0.5198765, 0.2123456]) + d = familiarity_signal_as_dict(signal) + assert set(d) == {"familiarity", "mean", "margin", "n", "method"} + assert d["familiarity"] == round(signal.familiarity, 6) + assert d["mean"] == round(signal.mean, 6) + assert d["margin"] == round(signal.margin, 6) + assert d["n"] == signal.n + assert d["method"] == signal.method diff --git a/tests_py/core/test_forward_model.py b/tests_py/core/test_forward_model.py index a4c1fdcf..fffada70 100644 --- a/tests_py/core/test_forward_model.py +++ b/tests_py/core/test_forward_model.py @@ -14,6 +14,7 @@ ERROR_DEADBAND, ForwardModelState, correction, + forward_model_state_as_dict, predict, prediction_error, run_forward_model, @@ -122,3 +123,26 @@ def test_reduces_feature_vector_for_the_model(self): traj = [scalar_of(f) for f in feats[:-1]] err = prediction_error(traj, scalar_of(feats[-1])) assert err > 0 + + +class TestForwardModelStateAsDict: + """issue #282: dataclass mutation blindspot — direct coverage of the + extracted free function (was ForwardModelState.as_dict).""" + + def test_rounds_all_fields(self): + # error and corrected each carry a 7th-decimal digit so round(x, 6) + # vs round(x, 7) and vs round(x, None) (int truncation) are all + # observably different (issue #282 mutation-testing gap: the + # original -0.0000001 / 0.5 values rounded identically at every + # precision — -0.0000001 rounds to 0.0 whether ndigits is 6 or + # None, and 0.5 has no 7th digit to lose). + state = ForwardModelState( + estimate=0.123456789, error=-0.1234567, corrected=0.5123456, n_updates=3 + ) + d = forward_model_state_as_dict(state) + assert d == { + "estimate": round(0.123456789, 6), + "error": round(-0.1234567, 6), + "corrected": round(0.5123456, 6), + "n_updates": 3, + } diff --git a/tests_py/core/test_goal_maintenance.py b/tests_py/core/test_goal_maintenance.py index 040a7ab6..05a43241 100644 --- a/tests_py/core/test_goal_maintenance.py +++ b/tests_py/core/test_goal_maintenance.py @@ -19,7 +19,16 @@ class TestBuildFromTriggers: def test_empty_or_none_triggers_yield_inactive_empty_goal(self): assert gm.build_goal_from_triggers(None) is gm.EMPTY_GOAL assert gm.build_goal_from_triggers([]) is gm.EMPTY_GOAL - assert not gm.EMPTY_GOAL.is_active + assert not gm.goal_vector_is_active(gm.EMPTY_GOAL) + + def test_is_active_on_any_single_signal_alone(self): + # Each feature family alone must be sufficient (a plain `or` chain, + # not `keywords or (entities and directories)` — issue #282 + # mutation-testing gap: an entities-only goal with no directories + # was previously indistinguishable from an `and`-combined mutant). + assert gm.goal_vector_is_active(gm.GoalVector(entities=frozenset({"e"}))) + assert gm.goal_vector_is_active(gm.GoalVector(directories=("d",))) + assert gm.goal_vector_is_active(gm.GoalVector(keywords=frozenset({"k"}))) def test_keyword_trigger_becomes_goal_keywords(self): triggers = [ @@ -29,7 +38,7 @@ def test_keyword_trigger_becomes_goal_keywords(self): } ] goal = gm.build_goal_from_triggers(triggers) - assert goal.is_active + assert gm.goal_vector_is_active(goal) assert "refactor" in goal.keywords assert "recall" in goal.keywords assert "pipeline" in goal.keywords @@ -97,7 +106,7 @@ def test_triggers_with_only_noise_yield_empty_goal(self): class TestBuildFromTask: def test_task_string_becomes_keywords(self): goal = gm.build_goal_from_task("investigate heat decay bug") - assert goal.is_active + assert gm.goal_vector_is_active(goal) assert "investigate" in goal.keywords assert "decay" in goal.keywords assert goal.label == "investigate heat decay bug" @@ -205,12 +214,12 @@ def test_custom_weight_respected(self): class TestGoalVectorDict: def test_as_dict_shape(self): goal = gm.build_goal_from_task("a task word", entities=["E"], directory="/d") - d = goal.as_dict() + d = gm.goal_vector_as_dict(goal) assert set(d) == {"label", "keywords", "entities", "directories", "is_active"} assert d["is_active"] is True assert d["keywords"] == sorted(d["keywords"]) # sorted for determinism def test_empty_goal_dict_inactive(self): - d = gm.EMPTY_GOAL.as_dict() + d = gm.goal_vector_as_dict(gm.EMPTY_GOAL) assert d["is_active"] is False assert d["keywords"] == [] diff --git a/tests_py/core/test_goal_maintenance_wiring.py b/tests_py/core/test_goal_maintenance_wiring.py index 55b1acb9..29ae7507 100644 --- a/tests_py/core/test_goal_maintenance_wiring.py +++ b/tests_py/core/test_goal_maintenance_wiring.py @@ -49,7 +49,7 @@ def test_store_with_no_triggers_gives_empty_goal(self): def test_triggers_promoted_to_active_goal(self): goal = write_gate.read_active_goal(_GoalStore(_GOAL_TRIGGERS)) - assert goal.is_active + assert gm.goal_vector_is_active(goal) assert "recall" in goal.keywords and "pipeline" in goal.keywords def test_reader_exception_is_non_fatal(self): diff --git a/tests_py/core/test_habituation.py b/tests_py/core/test_habituation.py index 8e60351e..4e1b6aa9 100644 --- a/tests_py/core/test_habituation.py +++ b/tests_py/core/test_habituation.py @@ -22,6 +22,7 @@ HabituationOutcome, effective_repeats, habituate_novelty, + habituation_outcome_as_dict, is_salient, response_gain, sensitization_factor, @@ -175,7 +176,7 @@ def test_recovery_reduces_suppression_over_time(): def test_outcome_as_dict_roundtrip(): out = habituate_novelty(0.7, "abc def", repeat_count=2) - d = out.as_dict() + d = habituation_outcome_as_dict(out) assert set(d) == { "signature", "modulated_novelty", @@ -193,3 +194,30 @@ def test_zero_novelty_stays_zero(): 0.0, "x", repeat_count=0, salience=1.0, hours_since_salient=0.0 ) assert out.modulated_novelty == 0.0 + + +def test_habituation_outcome_as_dict_full_shape_and_rounding(): + # Every float carries a 5th decimal digit so round(x, 4) vs round(x, 5) + # and vs round(x, None) (int truncation) are all observably different + # (issue #282 mutation-testing gap: habituate_novelty's own output + # values above only had 1-2 significant decimals, which rounds + # identically at every precision this dict serializes to). + out = HabituationOutcome( + signature="sig", + modulated_novelty=0.512345, + response_gain=0.698765, + sensitization=1.287654, + combined_gain=0.712345, + effective_repeats=2.198765, + suppressed=True, + ) + d = habituation_outcome_as_dict(out) + assert d == { + "signature": "sig", + "modulated_novelty": round(0.512345, 4), + "response_gain": round(0.698765, 4), + "sensitization": round(1.287654, 4), + "combined_gain": round(0.712345, 4), + "effective_repeats": round(2.198765, 4), + "suppressed": True, + } diff --git a/tests_py/core/test_procedural_memory.py b/tests_py/core/test_procedural_memory.py index bdbfa51b..c457074f 100644 --- a/tests_py/core/test_procedural_memory.py +++ b/tests_py/core/test_procedural_memory.py @@ -12,11 +12,14 @@ ProceduralSkill, HABITUAL_THRESHOLD, MIN_SKILL_SUPPORT, + action_step_key, context_signature, infer_target_kind, match_skills, mine_skills, normalize_actions, + procedural_skill_as_dict, + procedural_skill_is_habitual, reinforce, skill_id_for, ) @@ -42,7 +45,7 @@ def test_normalize_rich_form_infers_target_kind(): ] ) assert steps[0].target_kind == "test" - assert steps[0].key() == "edit_file:test" + assert action_step_key(steps[0]) == "edit_file:test" def test_infer_target_kind_classes(): @@ -89,7 +92,7 @@ def test_mine_extracts_recurring_routine(): skills = mine_skills(sessions) assert skills, "expected at least one mined skill" # The full 3-step routine should be among the mined skills. - seqs = {tuple(s.as_dict()["sequence"]) for s in skills} + seqs = {tuple(procedural_skill_as_dict(s)["sequence"]) for s in skills} assert ("read_file", "edit_file", "bash") in seqs top = skills[0] assert top.occurrences >= MIN_SKILL_SUPPORT @@ -103,7 +106,12 @@ def test_mine_counts_sequence_once_per_session(): ] skills = mine_skills(sessions) ab = next( - (s for s in skills if tuple(s.as_dict()["sequence"])[:2] == ("a", "b")), None + ( + s + for s in skills + if tuple(procedural_skill_as_dict(s)["sequence"])[:2] == ("a", "b") + ), + None, ) assert ab is not None # occurrences == number of sessions, not number of in-session repeats @@ -159,13 +167,13 @@ def test_habitual_graduation(): s = ProceduralSkill(sequence=(ActionStep("a"), ActionStep("b"))) for _ in range(HABITUAL_THRESHOLD): s = reinforce(s, "success") - assert s.is_habitual + assert procedural_skill_is_habitual(s) # one fewer success is not yet habitual s2 = ProceduralSkill( sequence=(ActionStep("a"), ActionStep("b")), success_count=HABITUAL_THRESHOLD - 1, ) - assert not s2.is_habitual + assert not procedural_skill_is_habitual(s2) # ── Situational retrieval ─────────────────────────────────────────────────── @@ -234,3 +242,32 @@ def test_match_priming_bonus(): {"domain": "cortex", "cwd": "/repo/cortex", "tools_used": ["grep"]}, [skill] ) assert primed[0]["score"] > unprimed[0]["score"] + + +# ── procedural_skill_as_dict shape (issue #282: dataclass mutation blindspot) — +def test_procedural_skill_as_dict_full_shape(): + skill = ProceduralSkill( + sequence=(ActionStep("read_file"), ActionStep("edit_file", "test")), + context_signature="cortex|repo", + occurrences=7, + success_count=6, + failure_count=1, + # A 5th-decimal digit so round(x, 4) vs round(x, 5) and vs + # round(x, None)/round(x) (int truncation) are all observably + # different — a value like 0.9 rounds identically at every + # precision and can't distinguish the mutants. + proficiency=0.912345, + last_seen="2026-01-01T00:00:00+00:00", + ) + d = procedural_skill_as_dict(skill) + assert d == { + "skill_id": skill.skill_id, + "sequence": ["read_file", "edit_file:test"], + "context_signature": "cortex|repo", + "occurrences": 7, + "success_count": 6, + "failure_count": 1, + "proficiency": round(0.912345, 4), + "is_habitual": True, # success_count 6 >= HABITUAL_THRESHOLD 5 + "last_seen": "2026-01-01T00:00:00+00:00", + } diff --git a/tests_py/core/test_sensory_buffer_dataclass.py b/tests_py/core/test_sensory_buffer_dataclass.py new file mode 100644 index 00000000..f0cdd17a --- /dev/null +++ b/tests_py/core/test_sensory_buffer_dataclass.py @@ -0,0 +1,55 @@ +"""Direct unit test for `buffer_item_to_dict` (issue #282). + +`BufferItem` is a plain `@dataclass`; mutmut categorically skips the body +of any `@dataclass`-decorated class (`mutmut/mutation/file_mutation.py:236`), +so the serialization logic that used to live on `BufferItem.to_dict` carries +zero mutation coverage unless exercised directly against the extracted free +function. No dedicated test module for `sensory_buffer.py` existed prior to +this file — `SensoryBuffer` itself is exercised indirectly via +`recall_pipeline` / `write_post_store` tests. +""" + +from __future__ import annotations + +from mcp_server.core.sensory_buffer import BufferItem, buffer_item_to_dict + + +def test_buffer_item_to_dict_round_trips_all_fields(): + item = BufferItem( + content="hello world", + tags=["a", "b"], + source="buffer", + directory="/tmp/proj", + domain="cortex", + importance=0.42, + valence=0.1, + created_at="2026-01-01T00:00:00+00:00", + ) + d = buffer_item_to_dict(item) + assert d == { + "content": "hello world", + "tags": ["a", "b"], + "source": "buffer", + "directory": "/tmp/proj", + "domain": "cortex", + "importance": 0.42, + "valence": 0.1, + "created_at": "2026-01-01T00:00:00+00:00", + } + + +def test_buffer_item_to_dict_is_not_the_same_object_field_references_reused(): + """A snapshot dict, not a mutation-hiding alias trick.""" + item = BufferItem( + content="x", + tags=["t"], + source="s", + directory="d", + domain="dom", + importance=1.0, + valence=-1.0, + ) + d = buffer_item_to_dict(item) + assert d["tags"] is item.tags # same list object — no defensive copy claimed + assert d["importance"] == 1.0 + assert d["valence"] == -1.0 diff --git a/tests_py/core/test_source_monitoring.py b/tests_py/core/test_source_monitoring.py index 66c7f480..ff62adbc 100644 --- a/tests_py/core/test_source_monitoring.py +++ b/tests_py/core/test_source_monitoring.py @@ -11,8 +11,10 @@ PERCEIVED, TOLD, UNKNOWN, + SourceJudgement, classify_source, extract_grounding, + source_judgement_as_dict, violates_source_claim, ) @@ -115,7 +117,35 @@ def test_gate_perceived_synonym(): def test_judgement_as_dict(): - d = classify_source("pg_schema.py line 43").as_dict() + d = source_judgement_as_dict(classify_source("pg_schema.py line 43")) assert d["attribution"] == PERCEIVED assert "grounding_refs" in d assert isinstance(d["grounding_refs"], list) + + +def test_source_judgement_as_dict_full_shape_and_rounding(): + # confidence carries a 5th-decimal digit so round(x, 4) vs round(x, 5) + # and vs round(x, None) (int truncation) are all observably different + # (issue #282 mutation-testing gap: classify_source's own confidence + # values in the fixture above didn't force this precision). 11 + # grounding refs so the [:10] truncation slice is exercised too. + refs = [f"ref{i}.py" for i in range(11)] + judgement = SourceJudgement( + attribution=PERCEIVED, + confidence=0.612345, + evidence_tag="observed", + perceptual_score=3, + told_score=1, + inferred_score=0, + grounding_refs=refs, + ) + d = source_judgement_as_dict(judgement) + assert d == { + "attribution": PERCEIVED, + "confidence": round(0.612345, 4), + "evidence_tag": "observed", + "perceptual_score": 3, + "told_score": 1, + "inferred_score": 0, + "grounding_refs": refs[:10], + } diff --git a/tests_py/core/test_value_learning.py b/tests_py/core/test_value_learning.py index 054d7ddf..31668f30 100644 --- a/tests_py/core/test_value_learning.py +++ b/tests_py/core/test_value_learning.py @@ -20,6 +20,7 @@ retention_bonus, retrieval_priority, td_update, + value_update_as_dict, ) @@ -157,14 +158,26 @@ def test_retrieval_priority_boost_and_penalty(): def test_value_update_as_dict_roundtrip(): + # Every float carries a 5th decimal digit and is not integer-valued — + # this is deliberate: round(x, 4) vs round(x, 5) and round(x, 4) vs + # round(x) (ndigits=None, which returns an int) must all be + # OBSERVABLY different for this test to be a real assertion rather + # than one that happens to pass regardless of the rounding precision + # (issue #282 mutation-testing gap: 0.5/0.55/1.0/0.1 round identically + # at 4 vs 5 digits and int-truncate to values that still compare equal + # via Python's int==float coercion). u = ValueUpdate( memory_id=7, - old_value=0.5, - new_value=0.55, - delta=0.5, - eligibility=1.0, - effective_alpha=0.1, + old_value=0.512345, + new_value=0.556789, + delta=0.512345, + eligibility=0.812345, + effective_alpha=0.198765, ) - d = u.as_dict() + d = value_update_as_dict(u) assert d["memory_id"] == 7 - assert d["new_value"] == 0.55 + assert d["new_value"] == round(0.556789, 4) + assert d["old_value"] == round(0.512345, 4) + assert d["delta"] == round(0.512345, 4) + assert d["eligibility"] == round(0.812345, 4) + assert d["effective_alpha"] == round(0.198765, 4) diff --git a/tests_py/core/test_wiki_axis_registry.py b/tests_py/core/test_wiki_axis_registry.py index a69b1aac..ee61489e 100644 --- a/tests_py/core/test_wiki_axis_registry.py +++ b/tests_py/core/test_wiki_axis_registry.py @@ -20,6 +20,13 @@ AXIS_LIFECYCLE, AXIS_PROVENANCE, AXES, + AxisRegistry, + AxisValue, + axis_registry_default_for, + axis_registry_get, + axis_registry_has, + axis_registry_names, + axis_registry_values, build_default_registry, did_you_mean, load_axis_registry, @@ -34,12 +41,12 @@ def test_default_registry_has_every_axis() -> None: reg = build_default_registry() for axis in AXES: - assert reg.values(axis), f"axis {axis!r} has no default values" + assert axis_registry_values(reg, axis), f"axis {axis!r} has no default values" def test_default_kinds_include_adr_runbook_explanation() -> None: reg = build_default_registry() - names = reg.names(AXIS_KIND) + names = axis_registry_names(reg, AXIS_KIND) assert "adr" in names assert "runbook" in names assert "explanation" in names @@ -47,20 +54,20 @@ def test_default_kinds_include_adr_runbook_explanation() -> None: def test_default_lifecycle_has_seedling_and_adr_subset() -> None: reg = build_default_registry() - names = reg.names(AXIS_LIFECYCLE) + names = axis_registry_names(reg, AXIS_LIFECYCLE) assert "seedling" in names # ADR-specific lifecycle values are present and carry applies_to_kinds. - proposed = reg.get(AXIS_LIFECYCLE, "proposed") + proposed = axis_registry_get(reg, AXIS_LIFECYCLE, "proposed") assert proposed is not None assert "adr" in proposed.applies_to_kinds def test_default_provenance_has_requires_generator_flag() -> None: reg = build_default_registry() - auto = reg.get(AXIS_PROVENANCE, "auto-generated") + auto = axis_registry_get(reg, AXIS_PROVENANCE, "auto-generated") assert auto is not None assert auto.requires_generator is True - human = reg.get(AXIS_PROVENANCE, "human") + human = axis_registry_get(reg, AXIS_PROVENANCE, "human") assert human is not None assert human.requires_generator is False @@ -69,7 +76,7 @@ def test_default_per_axis_is_unique() -> None: """Exactly one ``default=True`` value per axis (per kind scope for lifecycle).""" reg = build_default_registry() for axis in (AXIS_KIND, AXIS_AUDIENCE, AXIS_PROVENANCE): - defaults = [v for v in reg.values(axis) if v.default] + defaults = [v for v in axis_registry_values(reg, axis) if v.default] assert len(defaults) <= 1, f"{axis} has multiple defaults: {defaults}" @@ -101,7 +108,7 @@ def test_user_can_register_new_audience_via_schema_file(tmp_path: Path) -> None: ) reg = load_axis_registry(tmp_path) - ds = reg.get(AXIS_AUDIENCE, "data-scientist") + ds = axis_registry_get(reg, AXIS_AUDIENCE, "data-scientist") assert ds is not None assert ds.display_name == "Data scientist" assert any("dataset" in p.pattern for p in ds.patterns) @@ -122,7 +129,7 @@ def test_user_file_can_override_default(tmp_path: Path) -> None: ) reg = load_axis_registry(tmp_path) - dev = reg.get(AXIS_AUDIENCE, "developer") + dev = axis_registry_get(reg, AXIS_AUDIENCE, "developer") assert dev is not None assert dev.display_name == "Software engineer (overridden)" @@ -132,7 +139,7 @@ def test_missing_schema_folder_yields_defaults_only(tmp_path: Path) -> None: reg = load_axis_registry(tmp_path) default = build_default_registry() for axis in AXES: - assert reg.names(axis) == default.names(axis) + assert axis_registry_names(reg, axis) == axis_registry_names(default, axis) def test_malformed_schema_file_is_skipped(tmp_path: Path) -> None: @@ -145,7 +152,7 @@ def test_malformed_schema_file_is_skipped(tmp_path: Path) -> None: ) reg = load_axis_registry(tmp_path) # Valid file loaded; broken one ignored. - assert reg.has(AXIS_AUDIENCE, "ops-prod") + assert axis_registry_has(reg, AXIS_AUDIENCE, "ops-prod") # ── Detection (regex + tag aliases) ──────────────────────────────────── @@ -234,11 +241,50 @@ def test_reset_registry_picks_up_new_user_file(tmp_path: Path, monkeypatch) -> N from mcp_server.core.wiki_axis_registry import get_registry reg_before = get_registry() - assert not reg_before.has(AXIS_AUDIENCE, "pm") + assert not axis_registry_has(reg_before, AXIS_AUDIENCE, "pm") (schema_dir / "pm.md").write_text( "---\nname: pm\naxis: audience\ndisplay_name: Product manager\n---\n" ) reset_registry() reg_after = get_registry() - assert reg_after.has(AXIS_AUDIENCE, "pm") + assert axis_registry_has(reg_after, AXIS_AUDIENCE, "pm") + + +# ── Free functions against a registry MISSING the axis key entirely ──────── +# (issue #282 mutation-testing gap: `by_axis.get(axis, {})`'s `{}` default is +# only observable when `axis` is absent from `by_axis` altogether — every +# other test here uses build_default_registry()/load_axis_registry(), which +# always pre-populates every AXES key via _empty_registry(), so a mutant that +# replaces the `{}` default with `None` (AttributeError on `.values()`) +# never gets exercised without a registry that omits the key.) +class TestFreeFunctionsAgainstMissingAxisKey: + def test_values_on_missing_axis_is_empty_not_error(self): + registry = AxisRegistry(by_axis={}) + assert axis_registry_values(registry, AXIS_KIND) == () + + def test_names_on_missing_axis_is_empty_not_error(self): + registry = AxisRegistry(by_axis={}) + assert axis_registry_names(registry, AXIS_KIND) == frozenset() + + def test_get_on_missing_axis_is_none_not_error(self): + registry = AxisRegistry(by_axis={}) + assert axis_registry_get(registry, AXIS_KIND, "adr") is None + + def test_default_for_on_missing_axis_is_none_not_error(self): + registry = AxisRegistry(by_axis={}) + assert axis_registry_default_for(registry, AXIS_KIND) is None + + def test_default_for_looks_up_the_requested_axis_not_a_none_decoy(self): + # A mutant that replaces the lookup key `axis` with the literal + # `None` is invisible unless the registry ALSO carries a bucket + # keyed by `None` (dicts accept any hashable key) whose default + # differs from the real axis's — every other test's `by_axis` never + # has a `None` key at all, so `.get(None, {})` and `.get(axis, {})` + # returned the identical `{}` either way (issue #282 gap). + real = AxisValue(name="real", axis=AXIS_KIND, default=True) + decoy = AxisValue(name="decoy", axis=AXIS_KIND, default=True) + registry = AxisRegistry( + by_axis={AXIS_KIND: {"real": real}, None: {"decoy": decoy}} + ) + assert axis_registry_default_for(registry, AXIS_KIND) is real diff --git a/tests_py/core/test_wiki_coverage.py b/tests_py/core/test_wiki_coverage.py index d91a0ef1..a0432639 100644 --- a/tests_py/core/test_wiki_coverage.py +++ b/tests_py/core/test_wiki_coverage.py @@ -8,9 +8,16 @@ from mcp_server.core.wiki_coverage import ( SCOPES, + DomainCoverage, + ScopeCoverage, audit_all_domains, audit_domain, audit_files, + domain_coverage_covered_count, + domain_coverage_coverage_ratio, + domain_coverage_missing_count, + domain_coverage_missing_scopes, + file_coverage_coverage_ratio, list_domains, list_source_files, ) @@ -65,9 +72,9 @@ def test_returns_all_six_scopes(self, tmp_path): def test_fresh_domain_has_zero_coverage(self, tmp_path): c = audit_domain(str(tmp_path), "fresh") - assert c.covered_count == 0 - assert c.missing_count == len(SCOPES) - assert pytest.approx(c.coverage_ratio, abs=1e-9) == 0.0 + assert domain_coverage_covered_count(c) == 0 + assert domain_coverage_missing_count(c) == len(SCOPES) + assert pytest.approx(domain_coverage_coverage_ratio(c), abs=1e-9) == 0.0 def test_substantive_architecture_anchor_counts(self, tmp_path): wiki = str(tmp_path) @@ -225,6 +232,10 @@ def test_uncovered_files_reported(self, tmp_path, monkeypatch): assert c.source_file_count == 2 assert c.covered_file_count == 1 assert "lib.py" in c.uncovered_files + # 1/2 = 0.5: distinguishes division from multiplication (issue #282 + # mutation-testing gap — the vacuous 0/1-only cases elsewhere in + # this file can't tell `/` from `*` apart). + assert file_coverage_coverage_ratio(c) == 0.5 def test_unknown_domain_returns_zero(self, tmp_path, monkeypatch): """A domain not tied to a git repo gets a zero-file roll, not a crash.""" @@ -237,4 +248,49 @@ def test_unknown_domain_returns_zero(self, tmp_path, monkeypatch): c = audit_files(str(wiki), "ghost") assert c.source_root is None assert c.source_file_count == 0 - assert c.coverage_ratio == 1.0 # vacuous coverage + assert file_coverage_coverage_ratio(c) == 1.0 # vacuous coverage + + +class TestDomainCoverageFreeFunctions: + """Direct unit tests for the domain_coverage_* free functions (issue + #282: dataclass mutation blindspot). Constructs DomainCoverage/ + ScopeCoverage directly rather than through audit_domain so the case + with a NON-vacuous, partial ratio (needed to distinguish `/` from `*`, + and `sum(1 ...)` from `sum(2 ...)`) doesn't require writing real wiki + fixture pages.""" + + def _scope_coverage(self, *, covered: bool) -> ScopeCoverage: + return ScopeCoverage( + scope=SCOPES[0], + domain="d", + covered=covered, + page_count=1 if covered else 0, + anchor_page="reference/d/x.md" if covered else None, + suggested_path="reference/d/x.md", + ) + + def test_empty_scopes_is_vacuous_zero_not_one(self): + # Empty DomainCoverage: 0/0 must read as 0.0 (not covered), the + # opposite convention from FileCoverage's vacuous 1.0 — a real + # semantic difference this repo encodes, not an oversight. + empty = DomainCoverage(domain="d", scopes=[]) + assert domain_coverage_covered_count(empty) == 0 + assert domain_coverage_missing_count(empty) == 0 + assert domain_coverage_coverage_ratio(empty) == 0.0 + + def test_partial_coverage_exact_counts_and_ratio(self): + # 1 covered of 4 scopes: exact counts (not a doubled `sum(2 ...)` + # mutant) and division, not multiplication (1/4=0.25 vs 1*4=4). + cov = DomainCoverage( + domain="d", + scopes=[ + self._scope_coverage(covered=True), + self._scope_coverage(covered=False), + self._scope_coverage(covered=False), + self._scope_coverage(covered=False), + ], + ) + assert domain_coverage_covered_count(cov) == 1 + assert domain_coverage_missing_count(cov) == 3 + assert domain_coverage_coverage_ratio(cov) == 0.25 + assert len(domain_coverage_missing_scopes(cov)) == 3 diff --git a/tests_py/core/test_wiki_groomer.py b/tests_py/core/test_wiki_groomer.py index 0dbb7fc0..13b220a7 100644 --- a/tests_py/core/test_wiki_groomer.py +++ b/tests_py/core/test_wiki_groomer.py @@ -13,6 +13,7 @@ audit_page, audit_wiki, infer_kind_from_path, + page_audit_has_issues, parse_frontmatter, ) @@ -85,7 +86,7 @@ def test_canonical_adr_clean(self): audit = audit_page("adr/0042-use-lazy-heat.md", self.CANONICAL_ADR) # All required fields present, status valid, slug matches # 0042-use-lazy-heat. - assert not audit.has_issues + assert not page_audit_has_issues(audit) def test_missing_status(self): broken = self.CANONICAL_ADR.replace("status: accepted\n", "") diff --git a/tests_py/core/test_wiki_redirect.py b/tests_py/core/test_wiki_redirect.py index ddcc3d33..59e0cee1 100644 --- a/tests_py/core/test_wiki_redirect.py +++ b/tests_py/core/test_wiki_redirect.py @@ -11,6 +11,7 @@ is_redirect, parse_frontmatter, parse_redirect, + redirect_is_id_based, resolve_chain, ) @@ -35,7 +36,7 @@ def test_parse_id_redirect() -> None: r = parse_redirect(fm) assert r is not None assert r.target_id == _GOOD_ID - assert r.is_id_based + assert redirect_is_id_based(r) def test_parse_both_path_and_id_preserves_both() -> None: diff --git a/tests_py/core/test_wiki_schema_loader_registry.py b/tests_py/core/test_wiki_schema_loader_registry.py new file mode 100644 index 00000000..42dc7e2a --- /dev/null +++ b/tests_py/core/test_wiki_schema_loader_registry.py @@ -0,0 +1,35 @@ +"""Direct unit test for `wiki_registry_known_kind_names` (issue #282). + +`WikiRegistry` is a plain `@dataclass(frozen=True)`; mutmut categorically +skips the body of any `@dataclass`-decorated class +(`mutmut/mutation/file_mutation.py:236`), so the logic that used to live on +`WikiRegistry.known_kind_names` carries zero mutation coverage unless +exercised directly against the extracted free function. No dedicated test +module for `wiki_schema_loader.py`'s parsers existed prior to this file. +""" + +from __future__ import annotations + +from mcp_server.core.wiki_schema_loader import ( + KindDefinition, + WikiRegistry, + wiki_registry_known_kind_names, +) + + +def test_known_kind_names_returns_kind_keys(): + registry = WikiRegistry( + kinds={ + "adr": KindDefinition(name="adr", display_name="ADR", dir_name="adr"), + "rfc": KindDefinition(name="rfc", display_name="RFC", dir_name="rfc"), + }, + rules=[], + views={}, + triggers={}, + ) + assert wiki_registry_known_kind_names(registry) == {"adr", "rfc"} + + +def test_known_kind_names_empty_registry(): + registry = WikiRegistry(kinds={}, rules=[], views={}, triggers={}) + assert wiki_registry_known_kind_names(registry) == set() diff --git a/tests_py/core/test_wiki_view_executor.py b/tests_py/core/test_wiki_view_executor.py index c7ce09ce..e5e48e7a 100644 --- a/tests_py/core/test_wiki_view_executor.py +++ b/tests_py/core/test_wiki_view_executor.py @@ -17,6 +17,7 @@ _parse_kv_line, _parse_yamlish, compile_view, + compiled_view_ok, ) @@ -196,7 +197,7 @@ def test_default_table_is_pages(self): def test_unknown_table_rejected(self): result = compile_view("table: users") - assert not result.ok + assert not compiled_view_ok(result) assert "unknown table" in result.errors[0] assert result.sql == "" @@ -211,7 +212,7 @@ def test_valid_full_query(self): "limit: 20\n" ) result = compile_view(text) - assert result.ok + assert compiled_view_ok(result) assert "SELECT * FROM wiki.pages" in result.sql assert "WHERE kind = %s AND heat >= %s" in result.sql assert "ORDER BY heat DESC NULLS LAST" in result.sql @@ -225,17 +226,17 @@ def test_select_projection_whitelisted(self): def test_select_all_columns_invalid_falls_back_to_id(self): text = "table: pages\nselect: [not_a_col]" result = compile_view(text) - assert not result.ok + assert not compiled_view_ok(result) assert "SELECT id FROM" in result.sql def test_unknown_order_by_column_rejected(self): result = compile_view("table: pages\norder_by: evil_col") - assert not result.ok + assert not compiled_view_ok(result) assert "unknown order_by column" in result.errors[0] def test_invalid_direction_defaults_to_desc(self): result = compile_view("table: pages\norder_by: heat\ndirection: sideways") - assert not result.ok + assert not compiled_view_ok(result) assert "direction must be" in result.errors[0] assert "DESC" in result.sql @@ -249,7 +250,7 @@ def test_limit_clamped_to_minimum_one(self): def test_non_integer_limit_rejected_and_defaults(self): result = compile_view("table: pages\nlimit: not_a_number") - assert not result.ok + assert not compiled_view_ok(result) assert "limit must be an integer" in result.errors[0] assert result.params[-1] == 50 @@ -259,11 +260,11 @@ def test_no_where_clause_when_empty(self): def test_ok_property_true_without_errors(self): result = compile_view("table: pages") - assert result.ok is True + assert compiled_view_ok(result) is True def test_ok_property_false_with_errors(self): result = compile_view("table: users") - assert result.ok is False + assert compiled_view_ok(result) is False class TestCompileViewAdversarial: @@ -279,7 +280,7 @@ def test_sql_injection_in_where_value_is_bound_not_concatenated(self): def test_sql_injection_in_table_name_rejected(self): result = compile_view("table: pages; DROP TABLE pages;--") - assert not result.ok + assert not compiled_view_ok(result) assert "DROP TABLE" not in result.sql def test_sql_injection_in_column_name_rejected(self): @@ -292,7 +293,7 @@ def test_sql_injection_in_column_name_rejected(self): def test_sql_injection_in_order_by_rejected(self): result = compile_view("table: pages\norder_by: heat; DROP TABLE pages;--") - assert not result.ok + assert not compiled_view_ok(result) assert "DROP TABLE" not in result.sql def test_oversized_line_does_not_crash_parser(self): @@ -307,5 +308,5 @@ def test_deeply_nested_garbage_does_not_crash(self): def test_empty_input_produces_valid_default_query(self): result = compile_view("") - assert result.ok + assert compiled_view_ok(result) assert "wiki.pages" in result.sql diff --git a/tests_py/fuzz/test_corpus_replay.py b/tests_py/fuzz/test_corpus_replay.py index 0c7bab6a..eef53a12 100644 --- a/tests_py/fuzz/test_corpus_replay.py +++ b/tests_py/fuzz/test_corpus_replay.py @@ -9,22 +9,25 @@ Replaying needs no atheris: each harness exposes `consume(data: bytes)`. """ -import importlib.util -import sys from pathlib import Path import pytest +from fuzz import replay_corpus as _replay_corpus_module + _REPLAYER = Path(__file__).resolve().parents[2] / "fuzz" / "replay_corpus.py" def _replayer(): - spec = importlib.util.spec_from_file_location("replay_corpus", _REPLAYER) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - sys.modules.setdefault(spec.name, module) - spec.loader.exec_module(module) - return module + # A normal package-qualified import (not importlib.util.spec_from_file_ + # location, which registered this module as bare "replay_corpus" instead + # of "fuzz.replay_corpus" -- mutmut's trampoline dispatch keys mutants by + # the latter, so the file-path import made every mutant in this module + # unreachable by mutmut's coverage tracking: "tests recorded trampoline + # hits but none match any mutant key" (issue #282 sweep, reproduced via + # `mutmut run` on this file). Package-qualified import fixes both the + # test (unchanged behavior) and the mutation-testing blind spot. + return _replay_corpus_module def test_the_replayer_is_present(): @@ -42,9 +45,84 @@ def test_at_least_one_harness_exists(): def test_harness_holds_on_its_whole_corpus(harness): module = _replayer() target = next(h for h in module.discover() if h.name == harness) - corpus = list(target.inputs()) + corpus = list(module.harness_inputs(target)) assert corpus, ( f"{harness} has an empty corpus — a harness with no inputs asserts" " nothing, and every crash it once found would be unpinned" ) assert module.replay(target) == len(corpus) + + +# --- harness_consume error paths (issue #282: dataclass mutation blindspot) +# +# harness_consume is a free function carrying logic that used to live on +# Harness methods; @dataclass-decorated classes are excluded from mutmut's +# mutation generator entirely, so these error paths need direct negative- +# case tests or they carry zero mutation coverage no matter how the +# happy-path replay test above is parametrized (real harnesses always +# import cleanly and always expose consume()). + + +def test_harness_consume_missing_consume_raises_exact_message(tmp_path, monkeypatch): + """A harness module that fails to expose `consume(data: bytes)` must + raise RuntimeError with the exact diagnostic message, not a bare + AttributeError from an unguarded getattr — and the message content + (not just its type) is what tells a contributor what to fix.""" + monkeypatch.setattr(_replay_corpus_module, "FUZZ_DIR", tmp_path) + harness_name = "fuzz_missing_consume" + module_path = tmp_path / f"{harness_name}.py" + module_path.write_text("VALUE = 1\n") + harness = _replay_corpus_module.Harness(name=harness_name) + with pytest.raises(RuntimeError) as excinfo: + _replay_corpus_module.harness_consume(harness) + assert str(excinfo.value) == ( + f"{module_path}: no consume(data: bytes) —" + " every harness must expose one so its property is runnable" + " without atheris" + ) + + +def test_harness_consume_preregisters_module_before_exec(tmp_path, monkeypatch): + """harness_consume must insert the freshly-created module into + sys.modules[spec.name] BEFORE calling exec_module on it (see the + function's own docstring: @dataclass and other typing constructs + resolve annotations via sys.modules[cls.__module__], which would be + absent for a module loaded from a bare file path). A harness whose + top level checks `sys.modules[__name__]` observes itself, not None, + only if that ordering holds.""" + monkeypatch.setattr(_replay_corpus_module, "FUZZ_DIR", tmp_path) + harness_name = "fuzz_selfcheck" + (tmp_path / f"{harness_name}.py").write_text( + "import sys\n" + "assert sys.modules[__name__] is not None, (\n" + " 'harness module must be pre-registered in sys.modules before exec'\n" + ")\n" + "def consume(data: bytes) -> None:\n" + " pass\n" + ) + harness = _replay_corpus_module.Harness(name=harness_name) + consume = _replay_corpus_module.harness_consume(harness) + consume(b"") + + +def test_replay_wraps_consume_failure_with_input_context(tmp_path, monkeypatch): + """`replay()`'s try/except re-raises with which corpus input and which + original exception caused the failure -- the diagnostic context, not + just the fact that something in the corpus failed.""" + monkeypatch.setattr(_replay_corpus_module, "FUZZ_DIR", tmp_path) + monkeypatch.setattr(_replay_corpus_module, "CORPUS_DIR", tmp_path / "corpus") + harness_name = "fuzz_always_raises" + (tmp_path / f"{harness_name}.py").write_text( + "def consume(data: bytes) -> None:\n raise ValueError('boom')\n" + ) + corpus_dir = tmp_path / "corpus" / harness_name + corpus_dir.mkdir(parents=True) + (corpus_dir / "seed-1").write_bytes(b"x") + harness = _replay_corpus_module.Harness(name=harness_name) + with pytest.raises(AssertionError) as excinfo: + _replay_corpus_module.replay(harness) + assert str(excinfo.value) == ( + f"{harness_name} failed on committed corpus input seed-1: ValueError: boom" + ) + assert excinfo.value.__cause__ is not None + assert isinstance(excinfo.value.__cause__, ValueError) diff --git a/tests_py/handlers/test_headless_authoring_throttle.py b/tests_py/handlers/test_headless_authoring_throttle.py index 56eda9e8..f638e599 100644 --- a/tests_py/handlers/test_headless_authoring_throttle.py +++ b/tests_py/handlers/test_headless_authoring_throttle.py @@ -217,10 +217,12 @@ async def test_expired_deadline_skips_remaining( """Once the budget reports exhausted, remaining candidates are skipped. Deterministic: instead of racing a near-zero wall-clock budget against - a sleeping invoke (timing-flaky on a loaded CI), we patch - ``CycleBudget.exhausted`` to report not-exhausted on its first consult - and exhausted thereafter. With CONCURRENCY=1 the drains serialize, so - exactly the first candidate runs and the rest are skipped. + a sleeping invoke (timing-flaky on a loaded CI), we patch the + ``cycle_budget_exhausted`` free function (issue #282: CycleBudget's + logic moved off the @dataclass body) to report not-exhausted on its + first consult and exhausted thereafter. With CONCURRENCY=1 the drains + serialize, so exactly the first candidate runs and the rest are + skipped. """ import mcp_server.handlers.consolidation.headless_authoring as ha @@ -235,11 +237,11 @@ async def test_expired_deadline_skips_remaining( # consults → exhausted (remaining drains skip). No wall-clock timing. consults = {"n": 0} - def fake_exhausted(_self: Any) -> bool: + def fake_exhausted(_budget: Any) -> bool: consults["n"] += 1 return consults["n"] > 1 - monkeypatch.setattr(ha.CycleBudget, "exhausted", fake_exhausted) + monkeypatch.setattr(ha, "cycle_budget_exhausted", fake_exhausted) pages = [_make_wiki_page(tmp_path, f"page_{i}.md") for i in range(4)] @@ -514,42 +516,85 @@ class TestCycleBudget: """Unit tests for CycleBudget methods.""" def test_exhausted_by_time(self) -> None: - from mcp_server.handlers.consolidation.headless_authoring import CycleBudget + from mcp_server.handlers.consolidation.headless_authoring import ( + CycleBudget, + cycle_budget_exhausted, + ) budget = CycleBudget(deadline=time.monotonic() - 1.0, usd_cap=100.0) - assert budget.exhausted() + assert cycle_budget_exhausted(budget) def test_not_exhausted_when_time_remains(self) -> None: - from mcp_server.handlers.consolidation.headless_authoring import CycleBudget + from mcp_server.handlers.consolidation.headless_authoring import ( + CycleBudget, + cycle_budget_exhausted, + ) budget = CycleBudget(deadline=time.monotonic() + 60.0, usd_cap=100.0) - assert not budget.exhausted() + assert not cycle_budget_exhausted(budget) + + def test_exhausted_boundary_is_time_left_le_zero_not_lt_zero( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # A real wall-clock race can't hit "time_left exactly 0" or + # "exactly -1" deterministically, so the clock is monkeypatched + # to a fixed value — the real condition is `time_left() <= 0`, and + # a mutant weakening it to `< 0` (deadline reached but not yet + # negative) or to `<= 1` (a full second of slack added) would + # both be invisible to a test that only checks clearly-past or + # clearly-future deadlines (issue #282 mutation-testing gap). + import mcp_server.handlers.consolidation.headless_authoring as ha + import mcp_server.handlers.consolidation.cycle_types as cycle_types + + # CycleBudget + the free functions live in cycle_types (issue #276 + # split); `time.monotonic` is called from there, not from + # headless_authoring (which only re-exports the names). + monkeypatch.setattr(cycle_types.time, "monotonic", lambda: 100.0) + budget = ha.CycleBudget(deadline=100.0, usd_cap=100.0) # time_left == 0 exactly + assert ha.cycle_budget_exhausted(budget) is True + + budget_almost = ha.CycleBudget( + deadline=100.5, usd_cap=100.0 + ) # time_left == 0.5 + assert ha.cycle_budget_exhausted(budget_almost) is False def test_exhausted_by_usd(self) -> None: - from mcp_server.handlers.consolidation.headless_authoring import CycleBudget + from mcp_server.handlers.consolidation.headless_authoring import ( + CycleBudget, + cycle_budget_charge, + cycle_budget_exhausted, + ) budget = CycleBudget(deadline=time.monotonic() + 60.0, usd_cap=5.0) - budget.charge(5.0) - assert budget.exhausted() + cycle_budget_charge(budget, 5.0) + assert cycle_budget_exhausted(budget) def test_no_usd_cap_when_zero(self) -> None: - from mcp_server.handlers.consolidation.headless_authoring import CycleBudget + from mcp_server.handlers.consolidation.headless_authoring import ( + CycleBudget, + cycle_budget_charge, + cycle_budget_exhausted, + ) budget = CycleBudget(deadline=time.monotonic() + 60.0, usd_cap=0.0) - budget.charge(999.0) + cycle_budget_charge(budget, 999.0) # usd_cap <= 0 means unlimited — should NOT be exhausted by USD alone. - assert not budget.exhausted() + assert not cycle_budget_exhausted(budget) def test_charge_is_cumulative(self) -> None: - from mcp_server.handlers.consolidation.headless_authoring import CycleBudget + from mcp_server.handlers.consolidation.headless_authoring import ( + CycleBudget, + cycle_budget_charge, + cycle_budget_exhausted, + ) budget = CycleBudget(deadline=time.monotonic() + 60.0, usd_cap=10.0) - budget.charge(3.0) - budget.charge(4.0) + cycle_budget_charge(budget, 3.0) + cycle_budget_charge(budget, 4.0) assert abs(budget.usd_spent - 7.0) < 1e-9 - assert not budget.exhausted() - budget.charge(3.0) - assert budget.exhausted() + assert not cycle_budget_exhausted(budget) + cycle_budget_charge(budget, 3.0) + assert cycle_budget_exhausted(budget) # ── Test (e): cancellation cleanup ──────────────────────────────────────── diff --git a/tests_py/handlers/test_ingest_document_writers.py b/tests_py/handlers/test_ingest_document_writers.py index 1e3b8580..d18fa5ba 100644 --- a/tests_py/handlers/test_ingest_document_writers.py +++ b/tests_py/handlers/test_ingest_document_writers.py @@ -2,7 +2,10 @@ from __future__ import annotations -from mcp_server.core.document_model import DocumentProvenance +from mcp_server.core.document_model import ( + DocumentProvenance, + document_provenance_dedup_tag, +) from mcp_server.core.document_normalizer import NormalizedDocument, SectionMemory from mcp_server.handlers.ingest_document_writers import ( already_ingested, @@ -59,14 +62,18 @@ def test_none_when_never_ingested(self): assert already_ingested(_FakeStore(), _PROV) is None def test_returns_id_when_version_tag_present(self): - tag = _PROV.dedup_tag() + tag = document_provenance_dedup_tag(_PROV) store = _FakeStore(existing_by_tag={tag: [{"id": 7, "tags": [tag]}]}) assert already_ingested(store, _PROV) == 7 def test_new_version_is_not_a_hit(self): old = DocumentProvenance("/d.docx", "v0", "docx") store = _FakeStore( - existing_by_tag={old.dedup_tag(): [{"id": 7, "tags": [old.dedup_tag()]}]} + existing_by_tag={ + document_provenance_dedup_tag(old): [ + {"id": 7, "tags": [document_provenance_dedup_tag(old)]} + ] + } ) assert already_ingested(store, _PROV) is None @@ -79,12 +86,12 @@ class _NoTag: def test_queries_the_dedup_tag_with_bounded_limit(self): store = _FakeStore() already_ingested(store, _PROV) - assert store.last_tag_query == (_PROV.dedup_tag(), 5) + assert store.last_tag_query == (document_provenance_dedup_tag(_PROV), 5) def test_ignores_rows_that_lack_the_dedup_tag(self): # A tag-query hit whose row does not actually carry the dedup tag is # not a match (guards the in-row tag re-check). - tag = _PROV.dedup_tag() + tag = document_provenance_dedup_tag(_PROV) store = _FakeStore(existing_by_tag={tag: [{"id": 9, "tags": ["unrelated"]}]}) assert already_ingested(store, _PROV) is None diff --git a/tests_py/handlers/test_wiki_seed_codebase.py b/tests_py/handlers/test_wiki_seed_codebase.py index cbc509c5..75d0125d 100644 --- a/tests_py/handlers/test_wiki_seed_codebase.py +++ b/tests_py/handlers/test_wiki_seed_codebase.py @@ -75,13 +75,14 @@ def test_all_returned_kinds_are_registered() -> None: """ from mcp_server.core.wiki_axis_registry import ( AXIS_KIND, + axis_registry_values, build_default_registry, ) reg = build_default_registry() # Gather every tag alias across all registered kinds. all_aliases: set[str] = set() - for value in reg.values(AXIS_KIND): + for value in axis_registry_values(reg, AXIS_KIND): all_aliases.update(value.tag_aliases) all_aliases.add(value.name) diff --git a/tests_py/infrastructure/test_staging_resolve_sink.py b/tests_py/infrastructure/test_staging_resolve_sink.py index 33d873c9..c140fe3b 100644 --- a/tests_py/infrastructure/test_staging_resolve_sink.py +++ b/tests_py/infrastructure/test_staging_resolve_sink.py @@ -26,7 +26,10 @@ import psycopg # noqa: E402 from psycopg_pool import ConnectionPool # noqa: E402 -from mcp_server.core.streaming.backpressure_pipeline import BackpressurePipeline # noqa: E402 +from mcp_server.core.streaming.backpressure_pipeline import ( # noqa: E402 + BackpressurePipeline, + backpressure_pipeline_run, +) from mcp_server.infrastructure.staging_resolve_sink import ( # noqa: E402 build_edge_sink, build_entity_sink, @@ -117,8 +120,8 @@ def _run(pool): def test_resolves_and_conserves(pg_pool): ent, edge = _run(pg_pool) - er = ent.run() - rr = edge.run() + er = backpressure_pipeline_run(ent) + rr = backpressure_pipeline_run(edge) assert er.errors == [] and rr.errors == [] n_ent, n_rel = _counts(pg_pool) assert n_ent == 200 @@ -143,20 +146,24 @@ def test_entity_dedup_is_domain_scoped(pg_pool): (f"dup_{i}", f"dup_{i + 1}", "calls", 1.0, "code:projB") for i in range(9) ] for rows, conc in ((ents_a, 1), (ents_b, 1)): + backpressure_pipeline_run( + BackpressurePipeline( + source=_ListSource(rows), + sink_factory=lambda: build_entity_sink(pg_pool.connection), + max_batch=64, + queue_cap=4, + concurrency=conc, + ) + ) + rr = backpressure_pipeline_run( BackpressurePipeline( - source=_ListSource(rows), - sink_factory=lambda: build_entity_sink(pg_pool.connection), + source=_ListSource(edges_b), + sink_factory=lambda: build_edge_sink(pg_pool.connection), max_batch=64, queue_cap=4, - concurrency=conc, - ).run() - rr = BackpressurePipeline( - source=_ListSource(edges_b), - sink_factory=lambda: build_edge_sink(pg_pool.connection), - max_batch=64, - queue_cap=4, - concurrency=2, - ).run() + concurrency=2, + ) + ) assert rr.errors == [] with pg_pool.connection() as c: per_domain = c.execute( @@ -186,11 +193,11 @@ def test_entity_dedup_is_domain_scoped(pg_pool): def test_replay_is_idempotent(pg_pool): ent, edge = _run(pg_pool) - ent.run() - edge.run() + backpressure_pipeline_run(ent) + backpressure_pipeline_run(edge) before = _counts(pg_pool) # Re-run both stages — a crashed-then-resumed ingest must add nothing. - ent.run() - edge.run() + backpressure_pipeline_run(ent) + backpressure_pipeline_run(edge) after = _counts(pg_pool) assert after == before == (200, 199) diff --git a/tests_py/shared/test_wiki_classification.py b/tests_py/shared/test_wiki_classification.py index 190fb0d3..a6df6e42 100644 --- a/tests_py/shared/test_wiki_classification.py +++ b/tests_py/shared/test_wiki_classification.py @@ -15,6 +15,7 @@ Classification, Generator, all_known_kinds, + classification_to_frontmatter, is_legacy_kind, normalize_legacy_kind, ) @@ -57,9 +58,20 @@ def test_runbook_with_multi_audience() -> None: def test_adr_rejects_universal_lifecycle() -> None: - """ADR cannot use seedling/draft/active — those don't apply to ``adr``.""" - with pytest.raises(ValueError, match="kind=adr requires a lifecycle"): + """ADR cannot use seedling/draft/active — those don't apply to ``adr``. + + Asserts the ADR-subset list is real registry content (e.g. 'proposed'), + not an empty list — a mutant that looks the subset up under the wrong + axis key returns `[]` and would still satisfy a weaker + "kind=adr requires a lifecycle" substring match (issue #282 + mutation-testing gap). + """ + with pytest.raises(ValueError) as exc: Classification(kind="adr", lifecycle="seedling") + msg = str(exc.value) + assert "kind=adr requires a lifecycle from [" in msg + assert "'proposed'" in msg + assert "got 'seedling'" in msg def test_non_adr_rejects_adr_lifecycle() -> None: @@ -72,12 +84,21 @@ def test_non_adr_rejects_adr_lifecycle() -> None: def test_unknown_kind_rejected_with_suggestion() -> None: - """Per user direction 2026-05-12: reject + suggest, not warn-and-accept.""" + """Per user direction 2026-05-12: reject + suggest, not warn-and-accept. + + Asserts the exact "Did you mean" phrasing with the bracketed suggestion + list, not just substring presence of "adr" — the typo'd VALUE itself + ("adrs") already contains "adr" as a substring, so a weaker assertion + would pass even if the wrong axis were passed to did_you_mean (issue + #282 mutation-testing gap: this is a real behavior difference, since a + wrong axis makes the registry lookup empty and silently falls back to + the "No close matches" message instead). + """ with pytest.raises(ValueError) as exc: Classification(kind="adrs", lifecycle="seedling") # plural typo of 'adr' msg = str(exc.value) - assert "unknown kind" in msg - assert "adr" in msg # suggestion present + assert "unknown kind: 'adrs'" in msg # exact value echoed, not swapped for None + assert "Did you mean one of ['adr']" in msg assert "wiki/_schema/kinds" in msg # extension path mentioned @@ -89,8 +110,8 @@ def test_unknown_audience_rejected_with_extension_hint() -> None: audience=("developper",), # typo ) msg = str(exc.value) - assert "unknown audience" in msg - assert "developer" in msg # suggestion via difflib + assert "unknown audience: 'developper'" in msg # exact value, not None + assert "Did you mean one of ['developer']" in msg def test_unknown_provenance_rejected_with_extension_hint() -> None: @@ -101,16 +122,17 @@ def test_unknown_provenance_rejected_with_extension_hint() -> None: provenance="hummman", # typo ) msg = str(exc.value) - assert "unknown provenance" in msg - assert "human" in msg # suggestion present + assert "unknown provenance: 'hummman'" in msg # exact value, not None + assert "Did you mean one of ['human']" in msg def test_unknown_lifecycle_rejected_with_extension_hint() -> None: with pytest.raises(ValueError) as exc: Classification(kind="explanation", lifecycle="seedlinggg") msg = str(exc.value) - assert "unknown lifecycle" in msg - assert "seedling" in msg + assert "unknown lifecycle: 'seedlinggg'" in msg # exact value, not None + assert "Did you mean one of [" in msg + assert "'seedling'" in msg def test_completely_unrelated_value_rejected_without_suggestion() -> None: @@ -162,8 +184,13 @@ def test_human_provenance_does_not_require_generator() -> None: def test_empty_audience_rejected() -> None: - with pytest.raises(ValueError, match="audience must not be empty"): + # Exact-string match (not `pytest.raises(match=...)`, a substring + # search): a mutant that pads the message with "XX...XX" markers would + # still satisfy a substring search, since the original text remains + # contained inside the padded one (issue #282 mutation-testing gap). + with pytest.raises(ValueError) as exc: Classification(kind="explanation", lifecycle="seedling", audience=()) + assert str(exc.value) == "audience must not be empty" # ── Frontmatter serialization ────────────────────────────────────────── @@ -171,7 +198,7 @@ def test_empty_audience_rejected() -> None: def test_frontmatter_includes_required_axes() -> None: c = Classification(kind="how-to", lifecycle="active", audience=("developer",)) - fm = c.to_frontmatter() + fm = classification_to_frontmatter(c) assert fm["kind"] == "how-to" assert fm["lifecycle"] == "active" assert fm["audience"] == ["developer"] @@ -180,10 +207,16 @@ def test_frontmatter_includes_required_axes() -> None: def test_frontmatter_omits_empty_tags() -> None: c = Classification(kind="explanation", lifecycle="seedling") - fm = c.to_frontmatter() + fm = classification_to_frontmatter(c) assert "tags" not in fm +def test_frontmatter_includes_non_empty_tags_under_the_right_key() -> None: + c = Classification(kind="explanation", lifecycle="seedling", tags=("alpha", "beta")) + fm = classification_to_frontmatter(c) + assert fm["tags"] == ["alpha", "beta"] + + def test_frontmatter_serializes_generator_block() -> None: gen = Generator( model="claude-opus-4-7", @@ -197,7 +230,7 @@ def test_frontmatter_serializes_generator_block() -> None: provenance="auto-generated", generator=gen, ) - fm = c.to_frontmatter() + fm = classification_to_frontmatter(c) assert "generator" in fm assert fm["generator"] == { "model": "claude-opus-4-7", diff --git a/tests_py/test_doctor_mcp.py b/tests_py/test_doctor_mcp.py index 1fd469df..1d4a3463 100644 --- a/tests_py/test_doctor_mcp.py +++ b/tests_py/test_doctor_mcp.py @@ -29,6 +29,9 @@ _check_python_interpreter, _print_human, collect_mcp_report, + mcp_report_required_fails, + mcp_report_to_dict, + mcp_report_warnings, run_mcp, ) @@ -389,11 +392,63 @@ def test_report_includes_skipped_handshake(): assert any("stdio handshake" in s.get("name", "") for s in report.skipped), ( "stdio handshake must be reported as skipped, not silently absent" ) - serialized = report.to_dict() + serialized = mcp_report_to_dict(report) assert "skipped" in serialized assert serialized["skipped"], "skipped list must be non-empty" +# --- mcp_report_* free functions (issue #282: dataclass mutation blindspot) -- +# +# McpReport is a plain @dataclass; mutmut categorically skips the body of any +# @dataclass-decorated class, so the logic that used to live on +# required_fails/warnings/to_dict methods carries zero mutation coverage +# unless exercised directly against the extracted free functions. + + +def test_mcp_report_required_fails_filters_fail_severity_only(): + report = McpReport( + checks=[ + McpCheck(name="a", ok=False, detail="", severity="fail"), + McpCheck(name="b", ok=False, detail="", severity="warn"), + McpCheck(name="c", ok=True, detail="", severity="ok"), + ] + ) + fails = mcp_report_required_fails(report) + assert [c.name for c in fails] == ["a"] + + +def test_mcp_report_warnings_filters_warn_severity_only(): + report = McpReport( + checks=[ + McpCheck(name="a", ok=False, detail="", severity="fail"), + McpCheck(name="b", ok=False, detail="", severity="warn"), + McpCheck(name="c", ok=True, detail="", severity="ok"), + ] + ) + warns = mcp_report_warnings(report) + assert [c.name for c in warns] == ["b"] + + +def test_mcp_report_to_dict_ok_true_when_no_required_fails(): + report = McpReport( + checks=[McpCheck(name="a", ok=False, detail="", severity="warn")] + ) + serialized = mcp_report_to_dict(report) + assert serialized["ok"] is True + assert serialized["fail_count"] == 0 + assert serialized["warn_count"] == 1 + + +def test_mcp_report_to_dict_ok_false_when_required_fail_present(): + report = McpReport( + checks=[McpCheck(name="a", ok=False, detail="", severity="fail")] + ) + serialized = mcp_report_to_dict(report) + assert serialized["ok"] is False + assert serialized["fail_count"] == 1 + assert serialized["warn_count"] == 0 + + # --- check name uniqueness ---------------------------------------------