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