From 092395e7381d702c9bbea5d1f0984497f6a749d6 Mon Sep 17 00:00:00 2001 From: cdeust Date: Thu, 30 Jul 2026 16:47:46 +0200 Subject: [PATCH 1/3] fix(mutmut): extract 30 @dataclass-with-methods classes to free functions (issue #282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mutmut categorically skips the body of any @dataclass-decorated ClassDef (mutmut/mutation/file_mutation.py:236), including @staticmethod members and __post_init__ dunders, so logic placed on methods carries zero mutation coverage regardless of test-loader naming. Issues #262/#280/#283 established the fix (extract methods to free functions, keep the dataclass pure data) for ConstraintSet, RepoBadge, and Ranking; this closes the systemic sweep across the remaining 30 classes the issue's own AST scan found (26 files, spanning mcp_server/core, mcp_server/handlers/consolidation, mcp_server/shared, fuzz/, and benchmarks/lib/). Every extracted function now attributes a real, scoped mutation tally (0 attributed -> a number, matching the RepoBadge precedent) — verified via per-file `mutmut run ".x___mutmut_*"` isolation runs (a 26-file combined run gave unreliable results and was discarded; see the per-file tallies below). Surviving mutants were killed by strengthening assertions (precision-insensitive test fixtures, message-substring assertions matching the padded/typo'd input, missing negative-case coverage) or documented as equivalent at the use site. Also found and fixed one real regression risk while sweeping: a `getattr(goal, "is_active", False)` call site in recall_pipeline.py silently defaulted to False once the `is_active` property was removed, which would have disabled A3 goal-maintenance recall re-ranking with no signal — grep for `.attr` patterns alone does not catch getattr() defaults. Rebased onto a fast-moving origin/main mid-session (issue #276's headless_authoring.py split moved CycleBudget to a new cycle_types.py); the CycleBudget extraction was re-applied against the new file location. Closes #282 --- benchmarks/lib/verification_report.py | 74 +++--- fuzz/replay_corpus.py | 88 ++++--- mcp_server/core/ablation.py | 65 ++++-- mcp_server/core/attentional_control.py | 23 +- mcp_server/core/auto_curator.py | 3 +- mcp_server/core/conflict_monitor.py | 29 ++- mcp_server/core/context_assembly/budget.py | 33 ++- mcp_server/core/document_model.py | 61 +++-- mcp_server/core/document_normalizer.py | 6 +- mcp_server/core/dual_process_retrieval.py | 23 +- mcp_server/core/forward_model.py | 21 +- mcp_server/core/goal_maintenance.py | 38 ++-- mcp_server/core/habituation.py | 27 ++- mcp_server/core/procedural_memory.py | 88 ++++--- mcp_server/core/recall_pipeline.py | 11 +- mcp_server/core/sensory_buffer.py | 37 +-- mcp_server/core/source_monitoring.py | 27 ++- mcp_server/core/streaming/__init__.py | 10 +- .../core/streaming/adaptive_controller.py | 51 +++-- mcp_server/core/streaming/adaptive_writer.py | 14 +- .../core/streaming/backpressure_pipeline.py | 211 +++++++++-------- mcp_server/core/value_learning.py | 26 ++- mcp_server/core/wiki_axis_registry.py | 60 +++-- mcp_server/core/wiki_classifier.py | 8 +- mcp_server/core/wiki_coverage.py | 55 +++-- mcp_server/core/wiki_groomer.py | 14 +- mcp_server/core/wiki_kind_detection.py | 8 +- mcp_server/core/wiki_redirect.py | 15 +- mcp_server/core/wiki_schema_loader.py | 12 +- mcp_server/core/wiki_sync.py | 3 +- mcp_server/core/wiki_view_executor.py | 14 +- mcp_server/core/write_gate.py | 8 +- mcp_server/doctor_mcp.py | 49 ++-- .../consolidation/cycle_orchestration.py | 17 +- .../handlers/consolidation/cycle_types.py | 40 ++-- .../handlers/consolidation/distill_drain.py | 10 +- .../consolidation/headless_authoring.py | 12 +- .../consolidation/wiki_backlog_pass.py | 6 +- mcp_server/handlers/curate_wiki.py | 19 +- .../handlers/ingest_document_writers.py | 12 +- .../handlers/procedural_skill_writer.py | 4 +- mcp_server/handlers/wiki_synthesize.py | 3 +- mcp_server/handlers/wiki_view.py | 8 +- mcp_server/shared/wiki_classification.py | 214 +++++++++++------- .../benchmarks/test_verification_report.py | 112 +++++++++ .../core/context_assembly/test_decomposer.py | 31 ++- .../streaming/test_adaptive_controller.py | 38 ++-- .../streaming/test_backpressure_pipeline.py | 34 ++- tests_py/core/test_ablation.py | 33 +-- tests_py/core/test_attentional_control.py | 14 ++ tests_py/core/test_conflict_monitor.py | 36 +++ tests_py/core/test_confluence_parser.py | 10 +- tests_py/core/test_docx_parser.py | 10 +- tests_py/core/test_dual_process_retrieval.py | 16 ++ tests_py/core/test_forward_model.py | 24 ++ tests_py/core/test_goal_maintenance.py | 19 +- tests_py/core/test_goal_maintenance_wiring.py | 2 +- tests_py/core/test_habituation.py | 30 ++- tests_py/core/test_procedural_memory.py | 47 +++- .../core/test_sensory_buffer_dataclass.py | 55 +++++ tests_py/core/test_source_monitoring.py | 32 ++- tests_py/core/test_value_learning.py | 27 ++- tests_py/core/test_wiki_axis_registry.py | 72 ++++-- tests_py/core/test_wiki_coverage.py | 64 +++++- tests_py/core/test_wiki_groomer.py | 3 +- tests_py/core/test_wiki_redirect.py | 3 +- .../core/test_wiki_schema_loader_registry.py | 35 +++ tests_py/core/test_wiki_view_executor.py | 23 +- tests_py/fuzz/test_corpus_replay.py | 2 +- .../test_headless_authoring_throttle.py | 89 ++++++-- .../handlers/test_ingest_document_writers.py | 17 +- tests_py/handlers/test_wiki_seed_codebase.py | 3 +- .../test_staging_resolve_sink.py | 43 ++-- tests_py/shared/test_wiki_classification.py | 63 ++++-- tests_py/test_doctor_mcp.py | 57 ++++- 75 files changed, 1869 insertions(+), 732 deletions(-) create mode 100644 tests_py/benchmarks/test_verification_report.py create mode 100644 tests_py/core/test_sensory_buffer_dataclass.py create mode 100644 tests_py/core/test_wiki_schema_loader_registry.py 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..bd79f999 100644 --- a/fuzz/replay_corpus.py +++ b/fuzz/replay_corpus.py @@ -42,43 +42,57 @@ @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) + 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 +111,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 +139,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_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..ca804d69 100644 --- a/tests_py/fuzz/test_corpus_replay.py +++ b/tests_py/fuzz/test_corpus_replay.py @@ -42,7 +42,7 @@ 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" 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 --------------------------------------------- From 9069381345da654c34103a84d35e7279f4272394 Mon Sep 17 00:00:00 2001 From: cdeust Date: Thu, 30 Jul 2026 17:34:30 +0200 Subject: [PATCH 2/3] fix(mutmut): close 3 real survivors + 1 psycopg import bug in issue #282's sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified per-file mutant tallies for all 26 files/61 extracted functions from the #282 sweep via isolated `mutmut run ""` invocations (a combined 26-file run gave unreliable "survived"/"not checked" counts — discarded per this branch's own prior checkpoint). Two methodology bugs found while producing trustworthy numbers, both fixed at the source rather than worked around: - tests_py/fuzz/test_corpus_replay.py imported replay_corpus.py via importlib.util.spec_from_file_location under the bare name "replay_corpus", not the package-qualified "fuzz.replay_corpus" mutmut's trampoline dispatch expects -- every mutant in that file was invisible to mutation testing regardless of what the test asserted. Switched to a normal `from fuzz import replay_corpus` import (behavior unchanged, confirmed by the existing tests still passing); mutants went from 29/35 "not checked" to real killed/survived numbers. - tests_py/test_doctor_mcp.py's launcher smoke test needs scripts/ on disk; mutation_scope_run.sh (scratchpad tooling, not shipped) now adds it to also_copy so the scoped run's baseline-stats phase doesn't fail before scoring a single mutant (same class of gap as issue #293). Real surviving mutants found and killed with negative-case tests: - harness_consume (fuzz/replay_corpus.py): missing-consume() and module-preregistration paths were untested; added two tests pinning the exact RuntimeError message and the sys.modules[spec.name] ordering the function's own docstring documents as load-bearing. - replay() (same file, touched by this diff's call-site update): its AssertionError-wrapping path had no test that made consume() itself raise; added one. Remaining survivors in backpressure_pipeline_run (6) and exp_result_verdict (2) are the SAME equivalent mutants their source comments already document (thread `name=` kwargs; `cmp.get(op, False)`'s falsy default) -- verified by reading the actual mutant diffs, not re-litigated. Also fixes a real bug the sweep surfaced: benchmarks/lib/__init__.py eagerly imported BenchmarkDB (-> psycopg/psycopg_pool/pgvector) at package-init time, so importing the Postgres-independent benchmarks.lib.verification_report (one of the 26 extracted files) on a SQLite-only install raised ModuleNotFoundError before a single mutant could run. Every other consumer in benchmarks/lib/ already defers this import inside a function for exactly this reason; __init__.py did not follow its own package's convention. Fixed via a PEP 562 module __getattr__, with a subprocess-isolated regression test poisoning sys.modules for psycopg/psycopg_pool/pgvector. Full suite: 6864 passed, 5 skipped (was 6706 passed before this commit). Closes #282 --- benchmarks/lib/__init__.py | 37 ++++++- fuzz/replay_corpus.py | 9 ++ .../benchmarks/test_lib_init_no_psycopg.py | 100 ++++++++++++++++++ tests_py/fuzz/test_corpus_replay.py | 94 ++++++++++++++-- 4 files changed, 230 insertions(+), 10 deletions(-) create mode 100644 tests_py/benchmarks/test_lib_init_no_psycopg.py 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/fuzz/replay_corpus.py b/fuzz/replay_corpus.py index bd79f999..e4be0981 100644 --- a/fuzz/replay_corpus.py +++ b/fuzz/replay_corpus.py @@ -68,6 +68,15 @@ 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) 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..f87a5977 --- /dev/null +++ b/tests_py/benchmarks/test_lib_init_no_psycopg.py @@ -0,0 +1,100 @@ +"""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 + +_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.""" + 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/fuzz/test_corpus_replay.py b/tests_py/fuzz/test_corpus_replay.py index ca804d69..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(): @@ -48,3 +51,78 @@ def test_harness_holds_on_its_whole_corpus(harness): " 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) From f0f272b8a1497aa423c7d0e49843bb545784b3c5 Mon Sep 17 00:00:00 2001 From: cdeust Date: Thu, 30 Jul 2026 17:48:33 +0200 Subject: [PATCH 3/3] fix(tests): skip the psycopg-present sanity check when Postgres extras are absent test_benchmark_db_resolves_with_psycopg_present asserted the real BenchmarkDB resolves via benchmarks.lib's lazy __getattr__, but assumed psycopg is always importable in the test environment. CI's "Test (SQLite backend)" job installs without the Postgres extras, so this sanity check would fail there for the same reason BenchmarkDB is lazy in the first place. Guarded with pytest.importorskip("psycopg") -- the negative case (no psycopg -> loud ImportError, not silent) is already pinned by test_benchmark_db_still_reachable_lazily_and_fails_ loudly_without_psycopg above, so this only skips the redundant positive-case check on an install that cannot exercise it. Full suite: 6868 passed, 5 skipped, 0 failed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u --- tests_py/benchmarks/test_lib_init_no_psycopg.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests_py/benchmarks/test_lib_init_no_psycopg.py b/tests_py/benchmarks/test_lib_init_no_psycopg.py index f87a5977..6acf3970 100644 --- a/tests_py/benchmarks/test_lib_init_no_psycopg.py +++ b/tests_py/benchmarks/test_lib_init_no_psycopg.py @@ -30,6 +30,8 @@ import subprocess import sys +import pytest + _POISON = ( "import sys; " "sys.modules['psycopg'] = None; " @@ -76,7 +78,15 @@ def test_benchmark_db_still_reachable_lazily_and_fails_loudly_without_psycopg(): 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.""" + `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,