diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96a600b6..38ae061e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,22 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0 + # The Makefile scopes uv to repo-local cache and tool directories, + # so caching them preserves the Skylos tool environment, the + # duplication gate's PyChase script environment (Python 3.13), and + # the pinned pylint/df12 tool environments between runs. The + # Makefile and gate script pin those tool versions, so their + # hashes key the cache alongside the project lockfile. + - name: Cache uv tool and script environments + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + .uv-cache + .uv-tools + key: uv-envs-${{ runner.os }}-${{ hashFiles('uv.lock', 'Makefile', 'scripts/duplication_gate.py') }} + restore-keys: | + uv-envs-${{ runner.os }}- + - name: Install CLI tools run: | for tool in mbake ty ruff; do uv tool install "${tool}"; done @@ -68,6 +84,9 @@ jobs: - name: Run lint run: make lint + - name: Run duplication gate tests + run: make duplication-test + - name: Check spelling run: make spelling diff --git a/.gitignore b/.gitignore index 484f439f..b6f7321e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ __pycache__/ .grepai/ *.swo *~ +.pyscn/ diff --git a/AGENTS.md b/AGENTS.md index c9160ea3..25d34e51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,8 +95,18 @@ names the verified runtime caller. Match methods as `type = "method"`, rather than `"function"`. Use the named allow-list target only when an entry-point rule cannot describe the boundary: run - `make skylos-allow NAME=handler REASON="Loaded by plugin registry"` + + ```sh + make skylos-allow SYMBOL=episodic.api.handlers.handle_get_entity \ + REASON="Loaded by plugin registry" + ``` + with the verified caller in the reason. + The lint pipeline ends with the blocking PyChase duplication gate. + Prefer extracting shared logic over suppressing a finding; when parallel + structure is intentional, record a reasoned exception with + `make duplication-allow FIRST='path::qualname' [SECOND='path::qualname'] + REASON="why this stays"`, and remove entries the gate reports as stale. - **Formatting:** Adheres to formatting standards (`make check-fmt`; use `make fmt` to apply fixes). - **Typechecking:** Passes type checking (`make typecheck`). diff --git a/Makefile b/Makefile index 76089938..7bf40451 100644 --- a/Makefile +++ b/Makefile @@ -36,13 +36,19 @@ DF12_FUTURE_ANNOTATIONS = $(DF12_PYLINT_BASE) --enable=C9112 \ AMBRLEAKS = $(UV_ENV) $(UV) tool run --python $(DF12_PYTHON) \ --from '$(DF12_PYTHON_LINTS)' ambrleaks SKYLOS_VERSION = 4.33.2 -SKYLOS = $(UV_ENV) $(UV) tool run --from 'skylos==$(SKYLOS_VERSION)' skylos \ +# Pin the tool interpreter: Skylos parses sources with its own runtime `ast`, +# so an older default Python misreads the project's 3.14 syntax. +SKYLOS_CLI = $(UV_ENV) $(UV) tool run --python 3.14 \ + --from 'skylos==$(SKYLOS_VERSION)' skylos +SKYLOS = $(SKYLOS_CLI) \ --config-file pyproject.toml SKYLOS_PRODUCTION_TARGETS ?= alembic episodic openai_test_types.py +DUPLICATION_GATE = $(UV_ENV) $(UV) run scripts/duplication_gate.py .PHONY: help all clean build build-release lint fmt check-fmt \ markdownlint nixie spelling spelling-helper-test test typecheck \ crosshair check-migrations skylos-allow validate \ + duplication duplication-test duplication-allow \ local-k8s-up local-k8s-down local-k8s-status local-k8s-logs \ $(TOOLS) $(VENV_TOOLS) @@ -110,13 +116,41 @@ lint: check-architecture ## Run linters $(DF12_FUTURE_ANNOTATIONS) $(PYLINT_TARGETS) $(AMBRLEAKS) tests $(SKYLOS) $(SKYLOS_PRODUCTION_TARGETS) --category dead_code --gate --format concise --no-upload --no-provenance --no-grep-verify + $(DUPLICATION_GATE) check -skylos-allow: export SKYLOS_NAME = $(value NAME) -skylos-allow: export SKYLOS_REASON = $(value REASON) +duplication: ## Run the blocking code-duplication gate + $(DUPLICATION_GATE) check + +duplication-test: ## Run the duplication-gate helper tests + @$(UV_ENV) $(UV) run --no-project --python 3.13 \ + --with pytest==9.0.2 --with cyclopts --with 'pychase==0.1.0' \ + --with tomlkit --with 'hypothesis[asyncio]==6.165.6' \ + python -m pytest -c /dev/null --rootdir=. -p no:cacheprovider \ + scripts/tests + +# Accept FIRST/SECOND/REASON (and skylos SYMBOL) only from the make command +# line. `NAME` is ambient under WSL, which injects the hostname there, so the +# Skylos interface deliberately uses the otherwise-unset `SYMBOL` variable. +cli_value = $(if $(filter command line,$(origin $(1))),$(value $(1))) + +duplication-allow: export DUPLICATION_FIRST = $(call cli_value,FIRST) +duplication-allow: export DUPLICATION_SECOND = $(call cli_value,SECOND) +duplication-allow: export DUPLICATION_REASON = $(call cli_value,REASON) +duplication-allow: ## Record one reasoned duplication exception + @test -n "$${DUPLICATION_FIRST}" || { printf "Error: FIRST is required (path::qualname)\\n" >&2; exit 2; } + @test -n "$${DUPLICATION_REASON}" || { printf "Error: REASON is required for a duplication exception\\n" >&2; exit 2; } + $(DUPLICATION_GATE) allow --first "$${DUPLICATION_FIRST}" \ + $(if $(call cli_value,SECOND),--second "$${DUPLICATION_SECOND}",) \ + --reason "$${DUPLICATION_REASON}" + +skylos-allow: export SKYLOS_SYMBOL = $(call cli_value,SYMBOL) +skylos-allow: export SKYLOS_REASON = $(call cli_value,REASON) skylos-allow: ## Document one named Skylos exception, not an entry point - @test -n "$${SKYLOS_NAME}" || { printf "Error: NAME is required for a named whitelist exception\\n" >&2; exit 2; } + @test -n "$${SKYLOS_SYMBOL}" || { printf "Error: SYMBOL is required for a named whitelist exception\\n" >&2; exit 2; } @test -n "$${SKYLOS_REASON}" || { printf "Error: REASON is required for a named whitelist exception\\n" >&2; exit 2; } - $(SKYLOS) whitelist "$${SKYLOS_NAME}" --reason "$${SKYLOS_REASON}" + # The whitelist subcommand must be skylos's first argument; global + # options such as --config-file make the main parser treat it as a path. + $(SKYLOS_CLI) whitelist "$${SKYLOS_SYMBOL}" --reason "$${SKYLOS_REASON}" check-architecture: build ## Check hexagonal architecture import boundaries $(UV_ENV) $(UV) run hecate check diff --git a/benchmarks/dead_code/score.py b/benchmarks/dead_code/score.py index 89bbb608..10f7a4b1 100644 --- a/benchmarks/dead_code/score.py +++ b/benchmarks/dead_code/score.py @@ -26,11 +26,31 @@ scoring semantics are stable inputs to the benchmark evidence. """ +from __future__ import annotations + import dataclasses as dc import enum import typing as typ -from collections import abc as cabc -from pathlib import Path + +from benchmarks.score_support import ( + mapping as _mapping, +) + +if typ.TYPE_CHECKING: + from collections import abc as cabc + from pathlib import Path +from benchmarks.score_support import ( + positive_line as _positive_line, +) +from benchmarks.score_support import ( + relative_source_path, +) +from benchmarks.score_support import ( + sequence as _sequence, +) +from benchmarks.score_support import ( + string as _string, +) class Lane(enum.StrEnum): @@ -112,58 +132,6 @@ class LaneScore: unmatched_findings: int -def _mapping(value: object, *, context: str) -> cabc.Mapping[str, object]: - """Validate and return a string-keyed mapping.""" - if not isinstance(value, cabc.Mapping): - msg = f"{context} must be a JSON object" - raise TypeError(msg) - if not all(isinstance(key, str) for key in value): - msg = f"{context} keys must be strings" - raise TypeError(msg) - return typ.cast("cabc.Mapping[str, object]", value) - - -def _sequence(value: object, *, context: str) -> cabc.Sequence[object]: - """Validate and return a non-string sequence.""" - if not isinstance(value, cabc.Sequence) or isinstance(value, (str, bytes)): - msg = f"{context} must be a JSON array" - raise TypeError(msg) - return value - - -def _string(value: object, *, context: str) -> str: - """Validate and return a string value.""" - if not isinstance(value, str): - msg = f"{context} must be a string" - raise TypeError(msg) - return value - - -def _positive_line(value: object, *, context: str) -> int: - """Validate and return a positive, non-boolean line number.""" - if not isinstance(value, int) or isinstance(value, bool): - msg = f"{context} must be a positive integer" - raise TypeError(msg) - if value < 1: - msg = f"{context} must be positive" - raise ValueError(msg) - return value - - -def _relative_source_path(raw_path: object, corpus_root: Path) -> str: - """Normalize a finding path relative to the corpus root.""" - root = corpus_root.resolve() - path = Path(_string(raw_path, context="finding path")) - if not path.is_absolute(): - path = root / path - path = path.resolve() - try: - return path.relative_to(root).as_posix() - except ValueError as error: - msg = f"finding path {path} is outside corpus root {root}" - raise ValueError(msg) from error - - def parse_pyscn_findings( payload: object, *, @@ -238,7 +206,11 @@ def _parse_pyscn_finding( context=f"pyscn findings[{finding_index}].location", ) return Finding( - path=_relative_source_path(location.get("file_path"), corpus_root), + path=relative_source_path( + location.get("file_path"), + corpus_root, + subject="finding", + ), line=_positive_line( location.get("start_line"), context="pyscn finding start_line", @@ -296,7 +268,11 @@ def parse_skylos_findings( ) findings.append( Finding( - path=_relative_source_path(finding.get("file"), corpus_root), + path=relative_source_path( + finding.get("file"), + corpus_root, + subject="finding", + ), line=_positive_line( finding.get("line"), context=f"Skylos {category} line", diff --git a/benchmarks/duplication/README.md b/benchmarks/duplication/README.md new file mode 100644 index 00000000..04bb6f70 --- /dev/null +++ b/benchmarks/duplication/README.md @@ -0,0 +1,50 @@ +# Code-duplication detector benchmark + +This directory contains the reusable, tool-neutral corpus and normalizer for +the PyChase and pyscn clone-detection comparison. It is development evidence, +not part of the Episodic application or its test fixtures. + +`corpus/` is a deliberately small Python project. Its own `pyproject.toml` +bounds project-root discovery without configuring either detector. The +`pricing` module holds original routines; `reporting` clones them as labelled +Type-1 to Type-4 duplicates; `controls` holds structurally similar but +semantically distinct false-positive bait. + +`expectations.json` is the oracle. Each entry labels one pair of source units +before detector output is considered, assigns it to either the +`syntactic-clone` (Types 1-3) or `semantic-clone` (Type 4) lane, and explains +why merging the pair would or would not be a defensible refactor. Add a new +label only when its clone status can be decided without trusting a detector. Do +not change a label merely to make a detector result pass. + +The `score.py` module is intentionally specific to the two released JSON +schemas captured by this comparison. Reuse it for reruns of this corpus; add a +separate parser when evaluating a different detector rather than disguising +schema differences inside an existing parser. + +`configs/` holds the permissive pyscn capability settings. `results/` retains +the tool output, normalized scores, generational tuning tables, and the +production-scan adjudication from 2026-08-22. The large production report is +compressed with deterministic gzip metadata; its SHA-256 digest is recorded in +`production-adjudication.json`. + +Run the corpus commands from `benchmarks/duplication/corpus/`: + +```bash +uvx pyscn@1.29.1 analyze --select clones --json -c ../configs/pyscn-permissive.toml . +PYTHONHASHSEED=0 uvx pychase@0.1.0 --json --threshold 0.6 --min-lines 5 --min-nodes 10 . +``` + +Run the production comparison from the repository root: + +```bash +PYTHONHASHSEED=0 uvx pychase@0.1.0 --json --threshold 0.9 --min-lines 13 --min-nodes 50 episodic +``` + +`PYTHONHASHSEED` must be pinned for repository-scale PyChase runs: above 200 +units it buckets MinHash signatures with the built-in `hash()`, so an unpinned +seed makes near-threshold findings flicker between runs. + +The elapsed times recorded in `results/scores.json` are single wall-clock +observations, not performance benchmarks. They are retained to expose +order-of-magnitude differences only. diff --git a/benchmarks/duplication/__init__.py b/benchmarks/duplication/__init__.py new file mode 100644 index 00000000..ab4b9346 --- /dev/null +++ b/benchmarks/duplication/__init__.py @@ -0,0 +1 @@ +"""Duplication detector comparison corpus and scoring support.""" diff --git a/benchmarks/duplication/configs/pyscn-permissive.toml b/benchmarks/duplication/configs/pyscn-permissive.toml new file mode 100644 index 00000000..683480c1 --- /dev/null +++ b/benchmarks/duplication/configs/pyscn-permissive.toml @@ -0,0 +1,6 @@ +# Permissive capability-measurement settings for the corpus comparison. +# The floors match the PyChase permissive run; all clone types are enabled. +[clones] +min_lines = 5 +min_nodes = 10 +enabled_clone_types = ["type1", "type2", "type3", "type4"] diff --git a/benchmarks/duplication/corpus/__init__.py b/benchmarks/duplication/corpus/__init__.py new file mode 100644 index 00000000..c6637893 --- /dev/null +++ b/benchmarks/duplication/corpus/__init__.py @@ -0,0 +1,5 @@ +"""Public surface for the duplication detector corpus.""" + +from .pricing import order_total_price + +__all__ = ["order_total_price"] diff --git a/benchmarks/duplication/corpus/controls.py b/benchmarks/duplication/corpus/controls.py new file mode 100644 index 00000000..d3853c2b --- /dev/null +++ b/benchmarks/duplication/corpus/controls.py @@ -0,0 +1,184 @@ +# Benchmark source locations are intentionally stable. +"""Structurally similar but semantically distinct false-positive controls.""" + +import math + +PERCENT_SCALE = 100.0 +SECONDS_PER_MINUTE = 60.0 +SECONDS_PER_HOUR = 3600.0 + + +def parse_duration(text: str) -> float: + """Parse a duration such as ``"5m"`` or ``"30s"`` into seconds. + + Parameters + ---------- + text : str + Magnitude followed by a unit suffix (``s``, ``m``, or ``h``). + + Returns + ------- + float + Duration in seconds. + + Raises + ------ + ValueError + If the text is empty or the unit suffix is unknown. + """ + cleaned = text.strip().lower() + if not cleaned: + msg = "duration must not be empty" + raise ValueError(msg) + unit = cleaned[-1] + magnitude = cleaned[:-1] + if unit == "s": + scale = 1.0 + elif unit == "m": + scale = SECONDS_PER_MINUTE + elif unit == "h": + scale = SECONDS_PER_HOUR + else: + msg = f"unknown duration unit: {unit}" + raise ValueError(msg) + return float(magnitude) * scale + + +def parse_ratio(text: str) -> float: + """Parse a ratio such as ``"3:4"`` or ``"80%"`` into a fraction. + + Parameters + ---------- + text : str + Percentage, colon-separated ratio, or bare fraction. + + Returns + ------- + float + Ratio as a non-negative fraction. + + Raises + ------ + ValueError + If the text is empty, negative, or divides by zero. + """ + cleaned = text.strip() + if not cleaned: + msg = "ratio must not be empty" + raise ValueError(msg) + if cleaned.endswith("%"): + fraction = float(cleaned[:-1]) / PERCENT_SCALE + elif ":" in cleaned: + left, _, right = cleaned.partition(":") + denominator = float(right) + if not denominator: + msg = "ratio denominator must not be zero" + raise ValueError(msg) + fraction = float(left) / denominator + else: + fraction = float(cleaned) + if not math.isfinite(fraction): + msg = "ratio must be finite" + raise ValueError(msg) + if fraction < 0: + msg = "ratio must not be negative" + raise ValueError(msg) + return fraction + + +def build_export_manifest(name: str, entries: list[str]) -> dict[str, object]: + """Build the manifest describing one export bundle. + + Parameters + ---------- + name : str + Bundle name recorded in the manifest. + entries : list[str] + Relative paths included in the bundle. + + Returns + ------- + dict[str, object] + Manifest with the bundle name, sorted entries, and totals. + """ + unique_entries = sorted(set(entries)) + manifest: dict[str, object] = { + "bundle": name.strip(), + "entries": unique_entries, + "entry_count": len(unique_entries), + } + if len(unique_entries) != len(entries): + manifest["deduplicated"] = True + return manifest + + +def build_retention_policy(days: int, tiers: list[str]) -> dict[str, object]: + """Build the retention policy for archived episodes. + + Parameters + ---------- + days : int + Retention window in days; non-positive means indefinite. + tiers : list[str] + Storage tiers the policy cascades through, cheapest last. + + Returns + ------- + dict[str, object] + Policy with the window, cascade order, and expiry flag. + """ + cascade = [tier.strip().lower() for tier in tiers if tier.strip()] + policy: dict[str, object] = { + "window_days": max(days, 0), + "cascade": cascade, + "expires": days > 0, + } + if not cascade: + policy["cascade"] = ["standard"] + return policy + + +def longest_valid_streak(flags: list[bool]) -> int: + """Measure the longest run of consecutive valid flags. + + Parameters + ---------- + flags : list[bool] + Validity flags in observation order. + + Returns + ------- + int + Length of the longest unbroken run of ``True`` values. + """ + longest = 0 + current = 0 + for flag in flags: + if flag: + current += 1 + longest = max(longest, current) + else: + current = 0 + return longest + + +def count_state_changes(states: list[str]) -> int: + """Count how many times the observed state changes. + + Parameters + ---------- + states : list[str] + Observed states in observation order. + + Returns + ------- + int + Number of adjacent pairs with differing states. + """ + changes = 0 + previous: str | None = None + for state in states: + if previous is not None and state != previous: + changes += 1 + previous = state + return changes diff --git a/benchmarks/duplication/corpus/pricing.py b/benchmarks/duplication/corpus/pricing.py new file mode 100644 index 00000000..6bd5ca7d --- /dev/null +++ b/benchmarks/duplication/corpus/pricing.py @@ -0,0 +1,145 @@ +# Benchmark source locations are intentionally stable. +"""Original routines that the reporting module clones for comparison.""" + + +def order_total_price(items: list[dict[str, float]]) -> float: + """Sum taxable order lines into a rounded total. + + Parameters + ---------- + items : list[dict[str, float]] + Order lines with ``price``, ``quantity``, and optional ``taxable`` + and ``discount`` entries. + + Returns + ------- + float + Rounded, non-negative order total. + """ + total = 0.0 + for item in items: + line_price = item["price"] * item["quantity"] + if item.get("taxable"): + line_price *= 1.2 + if item.get("discount"): + line_price -= item["discount"] + total += line_price + if total < 0: + total = 0.0 + return round(total, 2) + + +def recent_error_messages( + events: list[dict[str, object]], + limit: int, +) -> list[str]: + """Collect the newest error messages up to a limit. + + Parameters + ---------- + events : list[dict[str, object]] + Event records ordered from newest to oldest. + limit : int + Maximum number of messages to collect. + + Returns + ------- + list[str] + Non-empty messages from error-level events. + """ + if limit <= 0: + return [] + messages: list[str] = [] + for event in events: + if event.get("level") != "error": + continue + text = str(event.get("message", "")) + if not text: + continue + messages.append(text) + if len(messages) >= limit: + break + return messages + + +def summarize_latencies(samples: list[float]) -> dict[str, float]: + """Summarize latency samples with bounds and a mean. + + Parameters + ---------- + samples : list[float] + Observed latency samples in milliseconds. + + Returns + ------- + dict[str, float] + Mapping with ``minimum``, ``maximum``, and ``mean`` entries. + """ + if not samples: + return {"minimum": 0.0, "maximum": 0.0, "mean": 0.0} + minimum = samples[0] + maximum = samples[0] + total = 0.0 + for sample in samples: + if sample < minimum: # noqa: PLR1730 - retain the copied bounds-tracking fixture. + minimum = sample + if sample > maximum: # noqa: PLR1730 - retain the copied bounds-tracking fixture. + maximum = sample + total += sample + return {"minimum": minimum, "maximum": maximum, "mean": total / len(samples)} + + +def weighted_average_score(rows: list[dict[str, float]]) -> float: + """Average valid row scores weighted by their sample size. + + Parameters + ---------- + rows : list[dict[str, float]] + Rows with ``score``, optional ``weight``, and a ``valid`` marker. + + Returns + ------- + float + Weighted mean score, or zero when no row is valid. + """ + weighted_total = 0.0 + weight_sum = 0.0 + for row in rows: + if not row.get("valid"): + continue + weight = row.get("weight", 1.0) + weighted_total += row["score"] * weight + weight_sum += weight + if weight_sum == 0.0: + return 0.0 + return weighted_total / weight_sum + + +class OrderExporter: + """Export orders after structural validation.""" + + def validate( # noqa: PLR6301 - the method-clone fixture requires instance methods. + self, payload: dict[str, object] + ) -> list[str]: + """Return the validation problems for an order payload. + + Parameters + ---------- + payload : dict[str, object] + Candidate order payload. + + Returns + ------- + list[str] + Human-readable validation problems; empty when valid. + """ + problems: list[str] = [] + for field in ("identifier", "customer", "total"): + if field not in payload: + problems.append(f"missing field: {field}") # noqa: PERF401 - retain the copied guard-loop fixture. + raw_total = payload.get("total") + if isinstance(raw_total, int | float) and raw_total < 0: + problems.append("total must not be negative") + if not payload.get("lines"): + problems.append("at least one line is required") + return problems diff --git a/benchmarks/duplication/corpus/pyproject.toml b/benchmarks/duplication/corpus/pyproject.toml new file mode 100644 index 00000000..dea127e7 --- /dev/null +++ b/benchmarks/duplication/corpus/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "episodic-duplication-corpus" +version = "0.0.0" +requires-python = ">=3.11" diff --git a/benchmarks/duplication/corpus/reporting.py b/benchmarks/duplication/corpus/reporting.py new file mode 100644 index 00000000..44a90398 --- /dev/null +++ b/benchmarks/duplication/corpus/reporting.py @@ -0,0 +1,155 @@ +# Benchmark source locations are intentionally stable. +"""Clones of the pricing module used as labelled true positives.""" + + +def order_total_price_copy(items: list[dict[str, float]]) -> float: + """Sum taxable order lines into a rounded total for a report. + + Parameters + ---------- + items : list[dict[str, float]] + Order lines with ``price``, ``quantity``, and optional ``taxable`` + and ``discount`` entries. + + Returns + ------- + float + Rounded, non-negative order total. + """ + # Type-1 clone: statements match pricing.order_total_price exactly. + total = 0.0 + for item in items: + line_price = item["price"] * item["quantity"] + if item.get("taxable"): + line_price *= 1.2 + if item.get("discount"): + line_price -= item["discount"] + total += line_price + if total < 0: + total = 0.0 + return round(total, 2) + + +def latest_alert_titles( + records: list[dict[str, object]], + maximum: int, +) -> list[str]: + """Collect the newest alert titles up to a maximum. + + Parameters + ---------- + records : list[dict[str, object]] + Alert records ordered from newest to oldest. + maximum : int + Maximum number of titles to collect. + + Returns + ------- + list[str] + Non-empty titles from error-level alerts. + """ + # Type-2 clone: identifiers renamed from pricing.recent_error_messages. + if maximum <= 0: + return [] + titles: list[str] = [] + for record in records: + if record.get("level") != "error": + continue + label = str(record.get("message", "")) + if not label: + continue + titles.append(label) + if len(titles) >= maximum: + break + return titles + + +def summarize_throughput(readings: list[float]) -> dict[str, float]: + """Summarize throughput readings with bounds, a mean, and a count. + + Parameters + ---------- + readings : list[float] + Observed throughput readings per interval. + + Returns + ------- + dict[str, float] + Mapping with ``minimum``, ``maximum``, ``mean``, and ``count`` + entries. + """ + # Type-3 clone: pricing.summarize_latencies with small modifications. + if not readings: + return {"minimum": 0.0, "maximum": 0.0, "mean": 0.0, "count": 0.0} + minimum = readings[0] + maximum = readings[0] + total = 0.0 + for reading in readings: + if reading < minimum: # noqa: PLR1730 - retain the copied bounds-tracking fixture. + minimum = reading + if reading > maximum: # noqa: PLR1730 - retain the copied bounds-tracking fixture. + maximum = reading + total += reading + count = float(len(readings)) + return { + "minimum": minimum, + "maximum": maximum, + "mean": total / count, + "count": count, + } + + +def mean_weighted_rating(entries: list[dict[str, float]]) -> float: + """Average valid entry ratings weighted by their sample size. + + Parameters + ---------- + entries : list[dict[str, float]] + Entries with ``score``, optional ``weight``, and a ``valid`` marker. + + Returns + ------- + float + Weighted mean rating, or zero when no entry is valid. + """ + # Type-4 clone: pricing.weighted_average_score via comprehensions. + weights = [entry.get("weight", 1.0) for entry in entries if entry.get("valid")] + weighted = [ + entry["score"] * entry.get("weight", 1.0) + for entry in entries + if entry.get("valid") + ] + if not weights: + return 0.0 + return sum(weighted) / sum(weights) + + +class InvoiceExporter: + """Export invoices after structural validation.""" + + def validate( # noqa: PLR6301 - the method-clone fixture requires instance methods. + self, document: dict[str, object] + ) -> list[str]: + """Return the validation problems for an invoice document. + + Parameters + ---------- + document : dict[str, object] + Candidate invoice document. + + Returns + ------- + list[str] + Human-readable validation problems; empty when valid. + """ + # Type-2 method clone of pricing.OrderExporter.validate. + faults: list[str] = [] + for field in ("identifier", "customer", "total"): + if field not in document: + faults.append(f"missing field: {field}") # noqa: PERF401 - retain the copied guard-loop fixture. + raw_amount = document.get("total") + if isinstance(raw_amount, int | float) and raw_amount < 0: + faults.append("total must not be negative") + if not document.get("lines"): + faults.append("at least one line is required") + return faults diff --git a/benchmarks/duplication/expectations.json b/benchmarks/duplication/expectations.json new file mode 100644 index 00000000..2c7fef7c --- /dev/null +++ b/benchmarks/duplication/expectations.json @@ -0,0 +1,182 @@ +[ + { + "identifier": "type1-order-total", + "lane": "syntactic-clone", + "is_clone": true, + "first": { + "path": "pricing.py", + "name": "order_total_price", + "start_line": 5, + "end_line": 29 + }, + "second": { + "path": "reporting.py", + "name": "order_total_price_copy", + "start_line": 5, + "end_line": 30 + }, + "rationale": "The statement sequences are identical; only the name, docstring, and a comment differ, so this is a Type-1 clone." + }, + { + "identifier": "type2-recent-messages", + "lane": "syntactic-clone", + "is_clone": true, + "first": { + "path": "pricing.py", + "name": "recent_error_messages", + "start_line": 32, + "end_line": 60 + }, + "second": { + "path": "reporting.py", + "name": "latest_alert_titles", + "start_line": 33, + "end_line": 62 + }, + "rationale": "Every statement corresponds one-to-one with only identifiers renamed, so this is a Type-2 clone." + }, + { + "identifier": "type3-summaries", + "lane": "syntactic-clone", + "is_clone": true, + "first": { + "path": "pricing.py", + "name": "summarize_latencies", + "start_line": 63, + "end_line": 87 + }, + "second": { + "path": "reporting.py", + "name": "summarize_throughput", + "start_line": 65, + "end_line": 97 + }, + "rationale": "The copy adds a count entry and hoists one expression but keeps the copied bounds-and-total loop, so this is a Type-3 clone." + }, + { + "identifier": "type4-weighted-mean", + "lane": "semantic-clone", + "is_clone": true, + "first": { + "path": "pricing.py", + "name": "weighted_average_score", + "start_line": 90, + "end_line": 113 + }, + "second": { + "path": "reporting.py", + "name": "mean_weighted_rating", + "start_line": 100, + "end_line": 122 + }, + "rationale": "Both compute the same weighted mean over valid entries, one with a loop and one with comprehensions, so this is a Type-4 clone." + }, + { + "identifier": "type2-validate-methods", + "lane": "syntactic-clone", + "is_clone": true, + "first": { + "path": "pricing.py", + "name": "OrderExporter.validate", + "start_line": 119, + "end_line": 143 + }, + "second": { + "path": "reporting.py", + "name": "InvoiceExporter.validate", + "start_line": 128, + "end_line": 153 + }, + "rationale": "The method bodies match statement-for-statement with renamed identifiers across two classes, so this is a Type-2 method clone." + }, + { + "identifier": "control-parsers", + "lane": "syntactic-clone", + "is_clone": false, + "first": { + "path": "controls.py", + "name": "parse_duration", + "start_line": 9, + "end_line": 42 + }, + "second": { + "path": "controls.py", + "name": "parse_ratio", + "start_line": 45, + "end_line": 80 + }, + "rationale": "Both parsers share the strip-guard-branch idiom, but the grammar, units, and error rules differ, so merging them would conflate two formats." + }, + { + "identifier": "control-builders", + "lane": "syntactic-clone", + "is_clone": false, + "first": { + "path": "controls.py", + "name": "build_export_manifest", + "start_line": 83, + "end_line": 106 + }, + "second": { + "path": "controls.py", + "name": "build_retention_policy", + "start_line": 109, + "end_line": 132 + }, + "rationale": "Both build a dict then patch one key, but the derivations and domain rules are unrelated, so this is incidental structural similarity." + }, + { + "identifier": "control-scanners", + "lane": "syntactic-clone", + "is_clone": false, + "first": { + "path": "controls.py", + "name": "longest_valid_streak", + "start_line": 135, + "end_line": 156 + }, + "second": { + "path": "controls.py", + "name": "count_state_changes", + "start_line": 159, + "end_line": 178 + }, + "rationale": "Both scan a sequence with two counters, but they compute different statistics with different update rules." + }, + { + "identifier": "control-accumulators", + "lane": "semantic-clone", + "is_clone": false, + "first": { + "path": "pricing.py", + "name": "order_total_price", + "start_line": 5, + "end_line": 29 + }, + "second": { + "path": "pricing.py", + "name": "weighted_average_score", + "start_line": 90, + "end_line": 113 + }, + "rationale": "Both accumulate over conditionally selected items, but a tax-and-discount total and a weighted mean are semantically unrelated." + }, + { + "identifier": "control-summaries", + "lane": "semantic-clone", + "is_clone": false, + "first": { + "path": "pricing.py", + "name": "summarize_latencies", + "start_line": 63, + "end_line": 87 + }, + "second": { + "path": "pricing.py", + "name": "weighted_average_score", + "start_line": 90, + "end_line": 113 + }, + "rationale": "Both are numeric folds over a list, but bounds-plus-mean and a weighted mean share no reusable statement structure." + } +] diff --git a/benchmarks/duplication/models.py b/benchmarks/duplication/models.py new file mode 100644 index 00000000..13b99b00 --- /dev/null +++ b/benchmarks/duplication/models.py @@ -0,0 +1,141 @@ +"""Tool-neutral data shapes for the duplication benchmark. + +The models give detector reports and the labelled corpus one stable vocabulary +for source spans, clone lanes, findings, and confusion-matrix scores. Parsers +construct these values before the benchmark scorer compares detector output +with its expectations. +""" + +import dataclasses as dc +import enum + + +class Lane(enum.StrEnum): + """A distinct static-analysis meaning of duplicated code. + + Attributes + ---------- + SYNTACTIC_CLONE : str + A clone detected from structural or textual similarity. + SEMANTIC_CLONE : str + A clone detected from equivalent behaviour despite different syntax. + """ + + SYNTACTIC_CLONE = "syntactic-clone" + SEMANTIC_CLONE = "semantic-clone" + + +@dc.dataclass(frozen=True, slots=True, kw_only=True) +class Fragment: + """A contiguous source span reported or labelled as one clone member. + + Attributes + ---------- + path : str + Corpus-relative source path containing the fragment. + start_line : int + One-based first source line of the fragment. + end_line : int + One-based last source line of the fragment. + + Notes + ----- + Paths are relative to the benchmark corpus. Parsers validate line + positivity and ordering before constructing a fragment. + """ + + path: str + start_line: int + end_line: int + + def overlaps(self, other: Fragment) -> bool: + """Report whether this fragment shares a source line with ``other``. + + Parameters + ---------- + other : Fragment + The source span to compare with this fragment. + + Returns + ------- + bool + ``True`` when both spans are in the same file and their inclusive + line ranges intersect; otherwise ``False``. + """ + return ( + self.path == other.path + and self.start_line <= other.end_line + and other.start_line <= self.end_line + ) + + +@dc.dataclass(frozen=True, slots=True, kw_only=True) +class Expectation: + """One labelled clone or non-clone pair in the benchmark corpus. + + Parameters + ---------- + identifier : str + Stable label for the pair in the benchmark oracle. + lane : Lane + Analysis lane in which the pair is scored. + is_clone : bool + Whether the pair is expected to be reported as a clone. + first, second : Fragment + The two source spans that make up the pair. + """ + + identifier: str + lane: Lane + is_clone: bool + first: Fragment + second: Fragment + + +@dc.dataclass(frozen=True, slots=True, kw_only=True) +class PairFinding: + """One detector-reported duplicate pair in benchmark-neutral form. + + Parameters + ---------- + first, second : Fragment + The two source spans reported by a detector. + lane : Lane + Benchmark lane used to compare the finding with expectations. + category : str + Detector-specific clone category retained for reporting. + similarity : float + Normalized detector similarity score in the inclusive range [0, 1]. + """ + + first: Fragment + second: Fragment + lane: Lane + category: str + similarity: float + + +@dc.dataclass(frozen=True, slots=True, kw_only=True) +class LaneScore: + """Confusion-matrix totals for one benchmark lane. + + Parameters + ---------- + true_positives, false_positives : int + Expected clone pairs correctly reported and unexpected pairs reported. + false_negatives, true_negatives : int + Expected clone pairs missed and non-clone pairs correctly omitted. + unmatched_findings : int + Findings that could not be matched to an oracle expectation. + + Notes + ----- + The scorer creates one instance per :class:`Lane` and uses the counters to + derive precision and recall in its benchmark report. + """ + + true_positives: int + false_positives: int + false_negatives: int + true_negatives: int + unmatched_findings: int diff --git a/benchmarks/duplication/parsers.py b/benchmarks/duplication/parsers.py new file mode 100644 index 00000000..6d384d6c --- /dev/null +++ b/benchmarks/duplication/parsers.py @@ -0,0 +1,211 @@ +"""Normalize PyChase and pyscn reports for duplication scoring. + +The public parser functions accept decoded detector JSON and a corpus root. +They validate each report's shape, normalize source locations, and return the +tool-neutral :class:`~benchmarks.duplication.models.PairFinding` values used by +the scorer. Detector-specific field names remain confined to this module. +""" + +import typing as typ + +from benchmarks.score_support import ( + mapping, + positive_line, + relative_source_path, + sequence, +) + +from .models import Fragment, Lane, PairFinding + +if typ.TYPE_CHECKING: + from collections import abc as cabc + from pathlib import Path + +_PYSCN_SEMANTIC_CLONE_TYPE = 4 + + +def _similarity(value: object, *, context: str) -> float: + """Validate and return a similarity score between zero and one.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + msg = f"{context} must be a number" + raise TypeError(msg) + score = float(value) + if not 0.0 <= score <= 1.0: + msg = f"{context} must be between 0.0 and 1.0" + raise ValueError(msg) + return score + + +def _fragment( + payload: cabc.Mapping[str, object], + *, + path_key: str, + context: str, + corpus_root: Path, +) -> Fragment: + """Normalize one reported fragment location.""" + start_line = positive_line( + payload.get("start_line"), context=f"{context} start_line" + ) + end_line = positive_line(payload.get("end_line"), context=f"{context} end_line") + if start_line > end_line: + msg = f"{context} start_line must not exceed end_line" + raise ValueError(msg) + return Fragment( + path=relative_source_path( + payload.get(path_key), corpus_root, subject="fragment" + ), + start_line=start_line, + end_line=end_line, + ) + + +def parse_pyscn_pairs( + payload: object, + *, + corpus_root: Path, +) -> tuple[PairFinding, ...]: + """Extract clone pairs from a pyscn unified JSON report. + + Parameters + ---------- + payload : object + Decoded pyscn report. It must contain an object-valued ``clone`` field + with a ``clone_pairs`` sequence, or ``null`` for no pairs. + corpus_root : pathlib.Path + Root directory used to normalize and constrain reported file paths. + + Returns + ------- + tuple[PairFinding, ...] + Pyscn pairs in report order, with clone type four mapped to the + semantic lane and other types mapped to the syntactic lane. + + Propagated errors + ----------------- + TypeError + If the report, pair fields, locations, or scalar fields have the wrong + shape or type. + ValueError + If a line, similarity, or source path fails validation. + """ + root = mapping(payload, context="pyscn payload") + clone = mapping(root.get("clone"), context="pyscn clone") + pairs = sequence( + clone.get("clone_pairs"), + context="pyscn clone.clone_pairs", + none_is_empty=True, + ) + return tuple( + _parse_pyscn_pair(raw_pair, pair_index=index, corpus_root=corpus_root) + for index, raw_pair in enumerate(pairs) + ) + + +def _parse_pyscn_pair( + raw_pair: object, + *, + pair_index: int, + corpus_root: Path, +) -> PairFinding: + """Normalize one validated pyscn clone pair.""" + context = f"pyscn clone_pairs[{pair_index}]" + pair = mapping(raw_pair, context=context) + clone_type = pair.get("type") + if not isinstance(clone_type, int) or isinstance(clone_type, bool): + msg = f"{context} type must be an integer" + raise TypeError(msg) + members = [ + _fragment( + mapping( + mapping(pair.get(member_key), context=f"{context}.{member_key}").get( + "location" + ), + context=f"{context}.{member_key}.location", + ), + path_key="file_path", + context=f"{context}.{member_key}.location", + corpus_root=corpus_root, + ) + for member_key in ("clone1", "clone2") + ] + lane = ( + Lane.SEMANTIC_CLONE + if clone_type == _PYSCN_SEMANTIC_CLONE_TYPE + else Lane.SYNTACTIC_CLONE + ) + return PairFinding( + first=members[0], + second=members[1], + lane=lane, + category=f"type-{clone_type}", + similarity=_similarity(pair.get("similarity"), context=f"{context} similarity"), + ) + + +def parse_pychase_pairs( + payload: object, + *, + corpus_root: Path, +) -> tuple[PairFinding, ...]: + """Extract candidate pairs from a PyChase JSON report. + + Parameters + ---------- + payload : object + Decoded PyChase report containing a ``candidates`` sequence. Each + candidate provides ``left`` and ``right`` member objects and a numeric + ``score``. + corpus_root : pathlib.Path + Root directory used to normalize and constrain reported file paths. + + Returns + ------- + tuple[PairFinding, ...] + PyChase candidates in report order, normalized to the syntactic lane. + + Propagated errors + ----------------- + TypeError + If the report, candidates, members, or scalar fields have the wrong + shape or type. + ValueError + If a line, score, or source path fails validation. + """ + root = mapping(payload, context="PyChase payload") + candidates = sequence(root.get("candidates"), context="PyChase candidates") + return tuple( + _parse_pychase_candidate( + raw_candidate, + candidate_index=index, + corpus_root=corpus_root, + ) + for index, raw_candidate in enumerate(candidates) + ) + + +def _parse_pychase_candidate( + raw_candidate: object, + *, + candidate_index: int, + corpus_root: Path, +) -> PairFinding: + """Normalize one validated PyChase candidate pair.""" + context = f"PyChase candidates[{candidate_index}]" + candidate = mapping(raw_candidate, context=context) + members = [ + _fragment( + mapping(candidate.get(member_key), context=f"{context}.{member_key}"), + path_key="file", + context=f"{context}.{member_key}", + corpus_root=corpus_root, + ) + for member_key in ("left", "right") + ] + return PairFinding( + first=members[0], + second=members[1], + lane=Lane.SYNTACTIC_CLONE, + category="candidate", + similarity=_similarity(candidate.get("score"), context=f"{context} score"), + ) diff --git a/benchmarks/duplication/results/production-adjudication.json b/benchmarks/duplication/results/production-adjudication.json new file mode 100644 index 00000000..02216580 --- /dev/null +++ b/benchmarks/duplication/results/production-adjudication.json @@ -0,0 +1,36 @@ +{ + "date": "2026-08-22", + "target": "episodic/ and openai_test_types.py at commit time", + "detector": "pychase 0.1.0", + "settings": { + "threshold": 0.9, + "min_lines": 13, + "min_nodes": 50, + "pythonhashseed": "0" + }, + "raw_candidates": 84, + "report": { + "file": "pychase-0.1.0-episodic.json.gz", + "sha256": "71b14189a0b4fe2a19065ec8209420f7e204554c164578f1efb45d3b82f99a88" + }, + "dispositions": { + "declarative_module_pairs": { + "count": 53, + "interpretation": "Both members sit in modules that are declarative by convention (storage record models, record/domain mappers, repository protocols, typed request/response modules). Identifier and literal normalization makes such declarations structurally identical without copy-paste, so these modules are excluded from the gate scan with documented patterns." + }, + "actionable": { + "count": 4, + "interpretation": "Genuine copy-paste fixed in this change: the verbatim _validate_async_callable helper duplicated across episodic/api/dependencies.py and episodic/canonical/health.py, and the three-way log_info/log_warning/log_error body in episodic/logging.py.", + "pairs": [ + "episodic/api/dependencies.py::_validate_async_callable ~ episodic/canonical/health.py::_validate_async_callable", + "episodic/logging.py::log_info ~ episodic/logging.py::log_warning", + "episodic/logging.py::log_info ~ episodic/logging.py::log_error", + "episodic/logging.py::log_warning ~ episodic/logging.py::log_error" + ] + }, + "allowlisted": { + "count": 27, + "interpretation": "Structurally parallel code adjudicated as intentional: Template Method subclasses over shared bases, typed facades over shared helpers, wire-format declarations, per-domain error dispatchers, and one accepted-debt pair (guest-bios TEI enrichment) recorded for a follow-up refactor. Each pair or unit is recorded with its reason under [tool.duplication_gate] in pyproject.toml." + } + } +} diff --git a/benchmarks/duplication/results/pychase-0.1.0-episodic.json.gz b/benchmarks/duplication/results/pychase-0.1.0-episodic.json.gz new file mode 100644 index 00000000..4ade4943 Binary files /dev/null and b/benchmarks/duplication/results/pychase-0.1.0-episodic.json.gz differ diff --git a/benchmarks/duplication/results/pychase-0.1.0.json b/benchmarks/duplication/results/pychase-0.1.0.json new file mode 100644 index 00000000..bed46b5c --- /dev/null +++ b/benchmarks/duplication/results/pychase-0.1.0.json @@ -0,0 +1,191 @@ +{ + "candidates": [ + { + "score": 1.0, + "left": { + "file": "pricing.py", + "start_line": 5, + "end_line": 29, + "qualname": "order_total_price", + "nodes": 96 + }, + "right": { + "file": "reporting.py", + "start_line": 5, + "end_line": 30, + "qualname": "order_total_price_copy", + "nodes": 96 + } + }, + { + "score": 1.0, + "left": { + "file": "pricing.py", + "start_line": 32, + "end_line": 60, + "qualname": "recent_error_messages", + "nodes": 96 + }, + "right": { + "file": "reporting.py", + "start_line": 33, + "end_line": 62, + "qualname": "latest_alert_titles", + "nodes": 96 + } + }, + { + "score": 0.75, + "left": { + "file": "pricing.py", + "start_line": 63, + "end_line": 87, + "qualname": "summarize_latencies", + "nodes": 107 + }, + "right": { + "file": "reporting.py", + "start_line": 65, + "end_line": 97, + "qualname": "summarize_throughput", + "nodes": 120 + } + }, + { + "score": 1.0, + "left": { + "file": "pricing.py", + "start_line": 116, + "end_line": 143, + "qualname": "OrderExporter", + "nodes": 116 + }, + "right": { + "file": "reporting.py", + "start_line": 125, + "end_line": 153, + "qualname": "InvoiceExporter", + "nodes": 116 + } + }, + { + "score": 1.0, + "left": { + "file": "pricing.py", + "start_line": 119, + "end_line": 143, + "qualname": "OrderExporter.validate", + "nodes": 113 + }, + "right": { + "file": "reporting.py", + "start_line": 128, + "end_line": 153, + "qualname": "InvoiceExporter.validate", + "nodes": 113 + } + } + ], + "groups": [ + { + "score": 1.0, + "locations": [ + { + "file": "pricing.py", + "start_line": 5, + "end_line": 29, + "qualname": "order_total_price", + "nodes": 96 + }, + { + "file": "reporting.py", + "start_line": 5, + "end_line": 30, + "qualname": "order_total_price_copy", + "nodes": 96 + } + ], + "pairs": 1 + }, + { + "score": 1.0, + "locations": [ + { + "file": "pricing.py", + "start_line": 32, + "end_line": 60, + "qualname": "recent_error_messages", + "nodes": 96 + }, + { + "file": "reporting.py", + "start_line": 33, + "end_line": 62, + "qualname": "latest_alert_titles", + "nodes": 96 + } + ], + "pairs": 1 + }, + { + "score": 0.75, + "locations": [ + { + "file": "pricing.py", + "start_line": 63, + "end_line": 87, + "qualname": "summarize_latencies", + "nodes": 107 + }, + { + "file": "reporting.py", + "start_line": 65, + "end_line": 97, + "qualname": "summarize_throughput", + "nodes": 120 + } + ], + "pairs": 1 + }, + { + "score": 1.0, + "locations": [ + { + "file": "pricing.py", + "start_line": 116, + "end_line": 143, + "qualname": "OrderExporter", + "nodes": 116 + }, + { + "file": "reporting.py", + "start_line": 125, + "end_line": 153, + "qualname": "InvoiceExporter", + "nodes": 116 + } + ], + "pairs": 1 + }, + { + "score": 1.0, + "locations": [ + { + "file": "pricing.py", + "start_line": 119, + "end_line": 143, + "qualname": "OrderExporter.validate", + "nodes": 113 + }, + { + "file": "reporting.py", + "start_line": 128, + "end_line": 153, + "qualname": "InvoiceExporter.validate", + "nodes": 113 + } + ], + "pairs": 1 + } + ] +} diff --git a/benchmarks/duplication/results/pyscn-1.29.1.json b/benchmarks/duplication/results/pyscn-1.29.1.json new file mode 100644 index 00000000..b67ce910 --- /dev/null +++ b/benchmarks/duplication/results/pyscn-1.29.1.json @@ -0,0 +1,1379 @@ +{ + "clone": { + "clones": [ + { + "id": 3, + "type": 0, + "location": { + "file_path": "controls.py", + "start_line": 45, + "end_line": 80, + "start_col": 0, + "end_col": 16 + }, + "content": "def parse_ratio(text: str) -\u003e float:\n \"\"\"Parse a ratio such as ``\"3:4\"`` or ``\"80%\"`` into a fraction.\n\n Parameters\n ----------\n text : str\n Percentage, colon-separated ratio, or bare fraction.\n\n Returns\n -------\n float\n Ratio as a non-negative fraction.\n\n Raises\n ------\n ValueError\n If the text is empty, negative, or divides by zero.\n \"\"\"\n cleaned = text.strip()\n if not cleaned:\n msg = \"ratio must not be empty\"\n raise ValueError(msg)\n if cleaned.endswith(\"%\"):\n return float(cleaned[:-1]) / PERCENT_SCALE\n if \":\" in cleaned:\n left, _, right = cleaned.partition(\":\")\n denominator = float(right)\n if not denominator:\n msg = \"ratio denominator must not be zero\"\n raise ValueError(msg)\n return float(left) / denominator\n value = float(cleaned)\n if value \u003c 0:\n msg = \"ratio must not be negative\"\n raise ValueError(msg)\n return value", + "hash": "df35542bb28d8a4b", + "size": 91, + "line_count": 36, + "complexity": 0 + }, + { + "id": 5, + "type": 0, + "location": { + "file_path": "controls.py", + "start_line": 83, + "end_line": 106, + "start_col": 0, + "end_col": 19 + }, + "content": "def build_export_manifest(name: str, entries: list[str]) -\u003e dict[str, object]:\n \"\"\"Build the manifest describing one export bundle.\n\n Parameters\n ----------\n name : str\n Bundle name recorded in the manifest.\n entries : list[str]\n Relative paths included in the bundle.\n\n Returns\n -------\n dict[str, object]\n Manifest with the bundle name, sorted entries, and totals.\n \"\"\"\n unique_entries = sorted(set(entries))\n manifest: dict[str, object] = {\n \"bundle\": name.strip(),\n \"entries\": unique_entries,\n \"entry_count\": len(unique_entries),\n }\n if len(unique_entries) != len(entries):\n manifest[\"deduplicated\"] = True\n return manifest", + "hash": "812b0f465b91100c", + "size": 71, + "line_count": 24, + "complexity": 0 + }, + { + "id": 6, + "type": 0, + "location": { + "file_path": "controls.py", + "start_line": 109, + "end_line": 132, + "start_col": 0, + "end_col": 17 + }, + "content": "def build_retention_policy(days: int, tiers: list[str]) -\u003e dict[str, object]:\n \"\"\"Build the retention policy for archived episodes.\n\n Parameters\n ----------\n days : int\n Retention window in days; non-positive means indefinite.\n tiers : list[str]\n Storage tiers the policy cascades through, cheapest last.\n\n Returns\n -------\n dict[str, object]\n Policy with the window, cascade order, and expiry flag.\n \"\"\"\n cascade = [tier.strip().lower() for tier in tiers if tier.strip()]\n policy: dict[str, object] = {\n \"window_days\": max(days, 0),\n \"cascade\": cascade,\n \"expires\": days \u003e 0,\n }\n if not cascade:\n policy[\"cascade\"] = [\"standard\"]\n return policy", + "hash": "7480c2c7d52aea57", + "size": 76, + "line_count": 24, + "complexity": 0 + }, + { + "id": 7, + "type": 0, + "location": { + "file_path": "controls.py", + "start_line": 135, + "end_line": 156, + "start_col": 0, + "end_col": 18 + }, + "content": "def longest_valid_streak(flags: list[bool]) -\u003e int:\n \"\"\"Measure the longest run of consecutive valid flags.\n\n Parameters\n ----------\n flags : list[bool]\n Validity flags in observation order.\n\n Returns\n -------\n int\n Length of the longest unbroken run of ``True`` values.\n \"\"\"\n longest = 0\n current = 0\n for flag in flags:\n if flag:\n current += 1\n longest = max(longest, current)\n else:\n current = 0\n return longest", + "hash": "f098bd92815387c7", + "size": 39, + "line_count": 22, + "complexity": 0 + }, + { + "id": 10, + "type": 0, + "location": { + "file_path": "controls.py", + "start_line": 159, + "end_line": 178, + "start_col": 0, + "end_col": 18 + }, + "content": "def count_state_changes(states: list[str]) -\u003e int:\n \"\"\"Count how many times the observed state changes.\n\n Parameters\n ----------\n states : list[str]\n Observed states in observation order.\n\n Returns\n -------\n int\n Number of adjacent pairs with differing states.\n \"\"\"\n changes = 0\n previous: str | None = None\n for state in states:\n if previous is not None and state != previous:\n changes += 1\n previous = state\n return changes", + "hash": "853f2860c0528969", + "size": 42, + "line_count": 20, + "complexity": 0 + }, + { + "id": 11, + "type": 0, + "location": { + "file_path": "pricing.py", + "start_line": 5, + "end_line": 29, + "start_col": 0, + "end_col": 26 + }, + "content": "def order_total_price(items: list[dict[str, float]]) -\u003e float:\n \"\"\"Sum taxable order lines into a rounded total.\n\n Parameters\n ----------\n items : list[dict[str, float]]\n Order lines with ``price``, ``quantity``, and optional ``taxable``\n and ``discount`` entries.\n\n Returns\n -------\n float\n Rounded, non-negative order total.\n \"\"\"\n total = 0.0\n for item in items:\n line_price = item[\"price\"] * item[\"quantity\"]\n if item.get(\"taxable\"):\n line_price *= 1.2\n if item.get(\"discount\"):\n line_price -= item[\"discount\"]\n total += line_price\n if total \u003c 0:\n total = 0.0\n return round(total, 2)", + "hash": "8fba7e04f95f6684", + "size": 70, + "line_count": 25, + "complexity": 0 + }, + { + "id": 13, + "type": 0, + "location": { + "file_path": "pricing.py", + "start_line": 32, + "end_line": 60, + "start_col": 0, + "end_col": 19 + }, + "content": "def recent_error_messages(\n events: list[dict[str, object]],\n limit: int,\n) -\u003e list[str]:\n \"\"\"Collect the newest error messages up to a limit.\n\n Parameters\n ----------\n events : list[dict[str, object]]\n Event records ordered from newest to oldest.\n limit : int\n Maximum number of messages to collect.\n\n Returns\n -------\n list[str]\n Non-empty messages from error-level events.\n \"\"\"\n messages: list[str] = []\n for event in events:\n if event.get(\"level\") != \"error\":\n continue\n text = str(event.get(\"message\", \"\"))\n if not text:\n continue\n messages.append(text)\n if len(messages) \u003e= limit:\n break\n return messages", + "hash": "c3a002abcb35b813", + "size": 80, + "line_count": 29, + "complexity": 0 + }, + { + "id": 15, + "type": 0, + "location": { + "file_path": "pricing.py", + "start_line": 63, + "end_line": 87, + "start_col": 0, + "end_col": 81 + }, + "content": "def summarize_latencies(samples: list[float]) -\u003e dict[str, float]:\n \"\"\"Summarize latency samples with bounds and a mean.\n\n Parameters\n ----------\n samples : list[float]\n Observed latency samples in milliseconds.\n\n Returns\n -------\n dict[str, float]\n Mapping with ``minimum``, ``maximum``, and ``mean`` entries.\n \"\"\"\n if not samples:\n return {\"minimum\": 0.0, \"maximum\": 0.0, \"mean\": 0.0}\n minimum = samples[0]\n maximum = samples[0]\n total = 0.0\n for sample in samples:\n if sample \u003c minimum: # noqa: PLR1730 - retain the copied bounds-tracking fixture.\n minimum = sample\n if sample \u003e maximum: # noqa: PLR1730 - retain the copied bounds-tracking fixture.\n maximum = sample\n total += sample\n return {\"minimum\": minimum, \"maximum\": maximum, \"mean\": total / len(samples)}", + "hash": "772b6585164605b1", + "size": 79, + "line_count": 25, + "complexity": 0 + }, + { + "id": 17, + "type": 0, + "location": { + "file_path": "pricing.py", + "start_line": 90, + "end_line": 113, + "start_col": 0, + "end_col": 38 + }, + "content": "def weighted_average_score(rows: list[dict[str, float]]) -\u003e float:\n \"\"\"Average valid row scores weighted by their sample size.\n\n Parameters\n ----------\n rows : list[dict[str, float]]\n Rows with ``score``, optional ``weight``, and a ``valid`` marker.\n\n Returns\n -------\n float\n Weighted mean score, or zero when no row is valid.\n \"\"\"\n weighted_total = 0.0\n weight_sum = 0.0\n for row in rows:\n if not row.get(\"valid\"):\n continue\n weight = row.get(\"weight\", 1.0)\n weighted_total += row[\"score\"] * weight\n weight_sum += weight\n if weight_sum == 0.0:\n return 0.0\n return weighted_total / weight_sum", + "hash": "bcc9f0dcbc803396", + "size": 66, + "line_count": 24, + "complexity": 0 + }, + { + "id": 19, + "type": 0, + "location": { + "file_path": "pricing.py", + "start_line": 116, + "end_line": 143, + "start_col": 0, + "end_col": 23 + }, + "content": "class OrderExporter:\n \"\"\"Export orders after structural validation.\"\"\"\n\n def validate( # noqa: PLR6301 - the method-clone fixture requires instance methods.\n self, payload: dict[str, object]\n ) -\u003e list[str]:\n \"\"\"Return the validation problems for an order payload.\n\n Parameters\n ----------\n payload : dict[str, object]\n Candidate order payload.\n\n Returns\n -------\n list[str]\n Human-readable validation problems; empty when valid.\n \"\"\"\n problems: list[str] = []\n for field in (\"identifier\", \"customer\", \"total\"):\n if field not in payload:\n problems.append(f\"missing field: {field}\") # noqa: PERF401 - retain the copied guard-loop fixture.\n raw_total = payload.get(\"total\")\n if isinstance(raw_total, int | float) and raw_total \u003c 0:\n problems.append(\"total must not be negative\")\n if not payload.get(\"lines\"):\n problems.append(\"at least one line is required\")\n return problems", + "hash": "cd9ba282f4165c21", + "size": 87, + "line_count": 28, + "complexity": 0 + }, + { + "id": 21, + "type": 0, + "location": { + "file_path": "reporting.py", + "start_line": 5, + "end_line": 30, + "start_col": 0, + "end_col": 26 + }, + "content": "def order_total_price_copy(items: list[dict[str, float]]) -\u003e float:\n \"\"\"Sum taxable order lines into a rounded total for a report.\n\n Parameters\n ----------\n items : list[dict[str, float]]\n Order lines with ``price``, ``quantity``, and optional ``taxable``\n and ``discount`` entries.\n\n Returns\n -------\n float\n Rounded, non-negative order total.\n \"\"\"\n # Type-1 clone: statements match pricing.order_total_price exactly.\n total = 0.0\n for item in items:\n line_price = item[\"price\"] * item[\"quantity\"]\n if item.get(\"taxable\"):\n line_price *= 1.2\n if item.get(\"discount\"):\n line_price -= item[\"discount\"]\n total += line_price\n if total \u003c 0:\n total = 0.0\n return round(total, 2)", + "hash": "3127400b0a7e04ee", + "size": 70, + "line_count": 26, + "complexity": 0 + }, + { + "id": 23, + "type": 0, + "location": { + "file_path": "reporting.py", + "start_line": 33, + "end_line": 62, + "start_col": 0, + "end_col": 17 + }, + "content": "def latest_alert_titles(\n records: list[dict[str, object]],\n maximum: int,\n) -\u003e list[str]:\n \"\"\"Collect the newest alert titles up to a maximum.\n\n Parameters\n ----------\n records : list[dict[str, object]]\n Alert records ordered from newest to oldest.\n maximum : int\n Maximum number of titles to collect.\n\n Returns\n -------\n list[str]\n Non-empty titles from error-level alerts.\n \"\"\"\n # Type-2 clone: identifiers renamed from pricing.recent_error_messages.\n titles: list[str] = []\n for record in records:\n if record.get(\"level\") != \"error\":\n continue\n label = str(record.get(\"message\", \"\"))\n if not label:\n continue\n titles.append(label)\n if len(titles) \u003e= maximum:\n break\n return titles", + "hash": "e4f15bd97a8390a8", + "size": 80, + "line_count": 30, + "complexity": 0 + }, + { + "id": 25, + "type": 0, + "location": { + "file_path": "reporting.py", + "start_line": 65, + "end_line": 97, + "start_col": 0, + "end_col": 5 + }, + "content": "def summarize_throughput(readings: list[float]) -\u003e dict[str, float]:\n \"\"\"Summarize throughput readings with bounds, a mean, and a count.\n\n Parameters\n ----------\n readings : list[float]\n Observed throughput readings per interval.\n\n Returns\n -------\n dict[str, float]\n Mapping with ``minimum``, ``maximum``, ``mean``, and ``count``\n entries.\n \"\"\"\n # Type-3 clone: pricing.summarize_latencies with small modifications.\n if not readings:\n return {\"minimum\": 0.0, \"maximum\": 0.0, \"mean\": 0.0, \"count\": 0.0}\n minimum = readings[0]\n maximum = readings[0]\n total = 0.0\n for reading in readings:\n if reading \u003c minimum: # noqa: PLR1730 - retain the copied bounds-tracking fixture.\n minimum = reading\n if reading \u003e maximum: # noqa: PLR1730 - retain the copied bounds-tracking fixture.\n maximum = reading\n total += reading\n count = float(len(readings))\n return {\n \"minimum\": minimum,\n \"maximum\": maximum,\n \"mean\": total / count,\n \"count\": count,\n }", + "hash": "c643879f2970038e", + "size": 88, + "line_count": 33, + "complexity": 0 + }, + { + "id": 27, + "type": 0, + "location": { + "file_path": "reporting.py", + "start_line": 100, + "end_line": 122, + "start_col": 0, + "end_col": 39 + }, + "content": "def mean_weighted_rating(entries: list[dict[str, float]]) -\u003e float:\n \"\"\"Average valid entry ratings weighted by their sample size.\n\n Parameters\n ----------\n entries : list[dict[str, float]]\n Entries with ``score``, optional ``weight``, and a ``valid`` marker.\n\n Returns\n -------\n float\n Weighted mean rating, or zero when no entry is valid.\n \"\"\"\n # Type-4 clone: pricing.weighted_average_score via comprehensions.\n weights = [entry.get(\"weight\", 1.0) for entry in entries if entry.get(\"valid\")]\n weighted = [\n entry[\"score\"] * entry.get(\"weight\", 1.0)\n for entry in entries\n if entry.get(\"valid\")\n ]\n if not weights:\n return 0.0\n return sum(weighted) / sum(weights)", + "hash": "0350058c0ceb64d2", + "size": 70, + "line_count": 23, + "complexity": 0 + }, + { + "id": 28, + "type": 0, + "location": { + "file_path": "reporting.py", + "start_line": 125, + "end_line": 153, + "start_col": 0, + "end_col": 21 + }, + "content": "class InvoiceExporter:\n \"\"\"Export invoices after structural validation.\"\"\"\n\n def validate( # noqa: PLR6301 - the method-clone fixture requires instance methods.\n self, document: dict[str, object]\n ) -\u003e list[str]:\n \"\"\"Return the validation problems for an invoice document.\n\n Parameters\n ----------\n document : dict[str, object]\n Candidate invoice document.\n\n Returns\n -------\n list[str]\n Human-readable validation problems; empty when valid.\n \"\"\"\n # Type-2 method clone of pricing.OrderExporter.validate.\n faults: list[str] = []\n for field in (\"identifier\", \"customer\", \"total\"):\n if field not in document:\n faults.append(f\"missing field: {field}\") # noqa: PERF401 - retain the copied guard-loop fixture.\n raw_amount = document.get(\"total\")\n if isinstance(raw_amount, int | float) and raw_amount \u003c 0:\n faults.append(\"total must not be negative\")\n if not document.get(\"lines\"):\n faults.append(\"at least one line is required\")\n return faults", + "hash": "2ef9baa88015cfa1", + "size": 87, + "line_count": 29, + "complexity": 0 + } + ], + "clone_pairs": [ + { + "id": 1, + "clone1": { + "id": 7, + "type": 4, + "location": { + "file_path": "controls.py", + "start_line": 135, + "end_line": 156, + "start_col": 0, + "end_col": 18 + }, + "hash": "f098bd92815387c7", + "size": 39, + "line_count": 22, + "complexity": 0 + }, + "clone2": { + "id": 10, + "type": 4, + "location": { + "file_path": "controls.py", + "start_line": 159, + "end_line": 178, + "start_col": 0, + "end_col": 18 + }, + "hash": "853f2860c0528969", + "size": 42, + "line_count": 20, + "complexity": 0 + }, + "similarity": 0.9282037686867012, + "distance": 27.8, + "type": 4, + "confidence": 0.85 + }, + { + "id": 2, + "clone1": { + "id": 13, + "type": 2, + "location": { + "file_path": "pricing.py", + "start_line": 32, + "end_line": 60, + "start_col": 0, + "end_col": 19 + }, + "hash": "c3a002abcb35b813", + "size": 80, + "line_count": 29, + "complexity": 0 + }, + "clone2": { + "id": 23, + "type": 2, + "location": { + "file_path": "reporting.py", + "start_line": 33, + "end_line": 62, + "start_col": 0, + "end_col": 17 + }, + "hash": "e4f15bd97a8390a8", + "size": 80, + "line_count": 30, + "complexity": 0 + }, + "similarity": 0.8499999999999999, + "distance": 10.799999999999999, + "type": 2, + "confidence": 0.95 + }, + { + "id": 3, + "clone1": { + "id": 19, + "type": 2, + "location": { + "file_path": "pricing.py", + "start_line": 116, + "end_line": 143, + "start_col": 0, + "end_col": 23 + }, + "hash": "cd9ba282f4165c21", + "size": 87, + "line_count": 28, + "complexity": 0 + }, + "clone2": { + "id": 28, + "type": 2, + "location": { + "file_path": "reporting.py", + "start_line": 125, + "end_line": 153, + "start_col": 0, + "end_col": 21 + }, + "hash": "2ef9baa88015cfa1", + "size": 87, + "line_count": 29, + "complexity": 0 + }, + "similarity": 0.8499999999999999, + "distance": 9.399999999999999, + "type": 2, + "confidence": 0.95 + }, + { + "id": 4, + "clone1": { + "id": 11, + "type": 2, + "location": { + "file_path": "pricing.py", + "start_line": 5, + "end_line": 29, + "start_col": 0, + "end_col": 26 + }, + "hash": "8fba7e04f95f6684", + "size": 70, + "line_count": 25, + "complexity": 0 + }, + "clone2": { + "id": 21, + "type": 2, + "location": { + "file_path": "reporting.py", + "start_line": 5, + "end_line": 30, + "start_col": 0, + "end_col": 26 + }, + "hash": "3127400b0a7e04ee", + "size": 70, + "line_count": 26, + "complexity": 0 + }, + "similarity": 0.8499999999999999, + "distance": 1, + "type": 2, + "confidence": 0.95 + }, + { + "id": 5, + "clone1": { + "id": 6, + "type": 4, + "location": { + "file_path": "controls.py", + "start_line": 109, + "end_line": 132, + "start_col": 0, + "end_col": 17 + }, + "hash": "7480c2c7d52aea57", + "size": 76, + "line_count": 24, + "complexity": 0 + }, + "clone2": { + "id": 7, + "type": 4, + "location": { + "file_path": "controls.py", + "start_line": 135, + "end_line": 156, + "start_col": 0, + "end_col": 18 + }, + "hash": "f098bd92815387c7", + "size": 39, + "line_count": 22, + "complexity": 0 + }, + "similarity": 0.8114455263465279, + "distance": 61.5, + "type": 4, + "confidence": 0.85 + }, + { + "id": 6, + "clone1": { + "id": 10, + "type": 4, + "location": { + "file_path": "controls.py", + "start_line": 159, + "end_line": 178, + "start_col": 0, + "end_col": 18 + }, + "hash": "853f2860c0528969", + "size": 42, + "line_count": 20, + "complexity": 0 + }, + "clone2": { + "id": 17, + "type": 4, + "location": { + "file_path": "pricing.py", + "start_line": 90, + "end_line": 113, + "start_col": 0, + "end_col": 38 + }, + "hash": "bcc9f0dcbc803396", + "size": 66, + "line_count": 24, + "complexity": 0 + }, + "similarity": 0.8092288833943493, + "distance": 51.39999999999999, + "type": 4, + "confidence": 0.85 + }, + { + "id": 7, + "clone1": { + "id": 7, + "type": 4, + "location": { + "file_path": "controls.py", + "start_line": 135, + "end_line": 156, + "start_col": 0, + "end_col": 18 + }, + "hash": "f098bd92815387c7", + "size": 39, + "line_count": 22, + "complexity": 0 + }, + "clone2": { + "id": 17, + "type": 4, + "location": { + "file_path": "pricing.py", + "start_line": 90, + "end_line": 113, + "start_col": 0, + "end_col": 38 + }, + "hash": "bcc9f0dcbc803396", + "size": 66, + "line_count": 24, + "complexity": 0 + }, + "similarity": 0.8074381874326104, + "distance": 47.09999999999999, + "type": 4, + "confidence": 0.85 + }, + { + "id": 8, + "clone1": { + "id": 10, + "type": 4, + "location": { + "file_path": "controls.py", + "start_line": 159, + "end_line": 178, + "start_col": 0, + "end_col": 18 + }, + "hash": "853f2860c0528969", + "size": 42, + "line_count": 20, + "complexity": 0 + }, + "clone2": { + "id": 11, + "type": 4, + "location": { + "file_path": "pricing.py", + "start_line": 5, + "end_line": 29, + "start_col": 0, + "end_col": 26 + }, + "hash": "8fba7e04f95f6684", + "size": 70, + "line_count": 25, + "complexity": 0 + }, + "similarity": 0.7794341383952682, + "distance": 53.599999999999994, + "type": 4, + "confidence": 0.85 + }, + { + "id": 9, + "clone1": { + "id": 10, + "type": 4, + "location": { + "file_path": "controls.py", + "start_line": 159, + "end_line": 178, + "start_col": 0, + "end_col": 18 + }, + "hash": "853f2860c0528969", + "size": 42, + "line_count": 20, + "complexity": 0 + }, + "clone2": { + "id": 21, + "type": 4, + "location": { + "file_path": "reporting.py", + "start_line": 5, + "end_line": 30, + "start_col": 0, + "end_col": 26 + }, + "hash": "3127400b0a7e04ee", + "size": 70, + "line_count": 26, + "complexity": 0 + }, + "similarity": 0.7794341383952682, + "distance": 53.599999999999994, + "type": 4, + "confidence": 0.85 + }, + { + "id": 10, + "clone1": { + "id": 17, + "type": 4, + "location": { + "file_path": "pricing.py", + "start_line": 90, + "end_line": 113, + "start_col": 0, + "end_col": 38 + }, + "hash": "bcc9f0dcbc803396", + "size": 66, + "line_count": 24, + "complexity": 0 + }, + "clone2": { + "id": 27, + "type": 4, + "location": { + "file_path": "reporting.py", + "start_line": 100, + "end_line": 122, + "start_col": 0, + "end_col": 39 + }, + "hash": "0350058c0ceb64d2", + "size": 70, + "line_count": 23, + "complexity": 0 + }, + "similarity": 0.7679606223973743, + "distance": 44.29999999999999, + "type": 4, + "confidence": 0.85 + }, + { + "id": 11, + "clone1": { + "id": 10, + "type": 4, + "location": { + "file_path": "controls.py", + "start_line": 159, + "end_line": 178, + "start_col": 0, + "end_col": 18 + }, + "hash": "853f2860c0528969", + "size": 42, + "line_count": 20, + "complexity": 0 + }, + "clone2": { + "id": 15, + "type": 4, + "location": { + "file_path": "pricing.py", + "start_line": 63, + "end_line": 87, + "start_col": 0, + "end_col": 81 + }, + "hash": "772b6585164605b1", + "size": 79, + "line_count": 25, + "complexity": 0 + }, + "similarity": 0.7537612237710818, + "distance": 62.300000000000004, + "type": 4, + "confidence": 0.85 + }, + { + "id": 12, + "clone1": { + "id": 7, + "type": 4, + "location": { + "file_path": "controls.py", + "start_line": 135, + "end_line": 156, + "start_col": 0, + "end_col": 18 + }, + "hash": "f098bd92815387c7", + "size": 39, + "line_count": 22, + "complexity": 0 + }, + "clone2": { + "id": 21, + "type": 4, + "location": { + "file_path": "reporting.py", + "start_line": 5, + "end_line": 30, + "start_col": 0, + "end_col": 26 + }, + "hash": "3127400b0a7e04ee", + "size": 70, + "line_count": 26, + "complexity": 0 + }, + "similarity": 0.7513549389220567, + "distance": 52.69999999999999, + "type": 4, + "confidence": 0.85 + }, + { + "id": 13, + "clone1": { + "id": 7, + "type": 4, + "location": { + "file_path": "controls.py", + "start_line": 135, + "end_line": 156, + "start_col": 0, + "end_col": 18 + }, + "hash": "f098bd92815387c7", + "size": 39, + "line_count": 22, + "complexity": 0 + }, + "clone2": { + "id": 11, + "type": 4, + "location": { + "file_path": "pricing.py", + "start_line": 5, + "end_line": 29, + "start_col": 0, + "end_col": 26 + }, + "hash": "8fba7e04f95f6684", + "size": 70, + "line_count": 25, + "complexity": 0 + }, + "similarity": 0.7513549389220567, + "distance": 52.69999999999999, + "type": 4, + "confidence": 0.85 + }, + { + "id": 14, + "clone1": { + "id": 5, + "type": 4, + "location": { + "file_path": "controls.py", + "start_line": 83, + "end_line": 106, + "start_col": 0, + "end_col": 19 + }, + "hash": "812b0f465b91100c", + "size": 71, + "line_count": 24, + "complexity": 0 + }, + "clone2": { + "id": 10, + "type": 4, + "location": { + "file_path": "controls.py", + "start_line": 159, + "end_line": 178, + "start_col": 0, + "end_col": 18 + }, + "hash": "853f2860c0528969", + "size": 42, + "line_count": 20, + "complexity": 0 + }, + "similarity": 0.7499527130953416, + "distance": 55.69999999999998, + "type": 4, + "confidence": 0.85 + }, + { + "id": 15, + "clone1": { + "id": 15, + "type": 3, + "location": { + "file_path": "pricing.py", + "start_line": 63, + "end_line": 87, + "start_col": 0, + "end_col": 81 + }, + "hash": "772b6585164605b1", + "size": 79, + "line_count": 25, + "complexity": 0 + }, + "clone2": { + "id": 25, + "type": 3, + "location": { + "file_path": "reporting.py", + "start_line": 65, + "end_line": 97, + "start_col": 0, + "end_col": 5 + }, + "hash": "c643879f2970038e", + "size": 88, + "line_count": 33, + "complexity": 0 + }, + "similarity": 0.7494252873563219, + "distance": 21.8, + "type": 3, + "confidence": 0.95 + }, + { + "id": 16, + "clone1": { + "id": 10, + "type": 4, + "location": { + "file_path": "controls.py", + "start_line": 159, + "end_line": 178, + "start_col": 0, + "end_col": 18 + }, + "hash": "853f2860c0528969", + "size": 42, + "line_count": 20, + "complexity": 0 + }, + "clone2": { + "id": 13, + "type": 4, + "location": { + "file_path": "pricing.py", + "start_line": 32, + "end_line": 60, + "start_col": 0, + "end_col": 19 + }, + "hash": "c3a002abcb35b813", + "size": 80, + "line_count": 29, + "complexity": 0 + }, + "similarity": 0.7476270227041109, + "distance": 61.3, + "type": 4, + "confidence": 0.85 + }, + { + "id": 17, + "clone1": { + "id": 10, + "type": 4, + "location": { + "file_path": "controls.py", + "start_line": 159, + "end_line": 178, + "start_col": 0, + "end_col": 18 + }, + "hash": "853f2860c0528969", + "size": 42, + "line_count": 20, + "complexity": 0 + }, + "clone2": { + "id": 23, + "type": 4, + "location": { + "file_path": "reporting.py", + "start_line": 33, + "end_line": 62, + "start_col": 0, + "end_col": 17 + }, + "hash": "e4f15bd97a8390a8", + "size": 80, + "line_count": 30, + "complexity": 0 + }, + "similarity": 0.7476270227041109, + "distance": 61.3, + "type": 4, + "confidence": 0.85 + }, + { + "id": 18, + "clone1": { + "id": 10, + "type": 4, + "location": { + "file_path": "controls.py", + "start_line": 159, + "end_line": 178, + "start_col": 0, + "end_col": 18 + }, + "hash": "853f2860c0528969", + "size": 42, + "line_count": 20, + "complexity": 0 + }, + "clone2": { + "id": 25, + "type": 4, + "location": { + "file_path": "reporting.py", + "start_line": 65, + "end_line": 97, + "start_col": 0, + "end_col": 5 + }, + "hash": "c643879f2970038e", + "size": 88, + "line_count": 33, + "complexity": 0 + }, + "similarity": 0.7389456946956141, + "distance": 71.10000000000001, + "type": 4, + "confidence": 0.85 + }, + { + "id": 19, + "clone1": { + "id": 7, + "type": 4, + "location": { + "file_path": "controls.py", + "start_line": 135, + "end_line": 156, + "start_col": 0, + "end_col": 18 + }, + "hash": "f098bd92815387c7", + "size": 39, + "line_count": 22, + "complexity": 0 + }, + "clone2": { + "id": 25, + "type": 4, + "location": { + "file_path": "reporting.py", + "start_line": 65, + "end_line": 97, + "start_col": 0, + "end_col": 5 + }, + "hash": "c643879f2970038e", + "size": 88, + "line_count": 33, + "complexity": 0 + }, + "similarity": 0.7376745343231736, + "distance": 70.69999999999999, + "type": 4, + "confidence": 0.85 + }, + { + "id": 20, + "clone1": { + "id": 7, + "type": 4, + "location": { + "file_path": "controls.py", + "start_line": 135, + "end_line": 156, + "start_col": 0, + "end_col": 18 + }, + "hash": "f098bd92815387c7", + "size": 39, + "line_count": 22, + "complexity": 0 + }, + "clone2": { + "id": 15, + "type": 4, + "location": { + "file_path": "pricing.py", + "start_line": 63, + "end_line": 87, + "start_col": 0, + "end_col": 81 + }, + "hash": "772b6585164605b1", + "size": 79, + "line_count": 25, + "complexity": 0 + }, + "similarity": 0.7335768532709321, + "distance": 61.900000000000006, + "type": 4, + "confidence": 0.85 + } + ], + "clone_groups": [ + { + "id": 0, + "clones": [ + { + "id": 3, + "type": 4, + "location": { + "file_path": "controls.py", + "start_line": 45, + "end_line": 80, + "start_col": 0, + "end_col": 16 + }, + "hash": "df35542bb28d8a4b", + "size": 91, + "line_count": 36, + "complexity": 0 + }, + { + "id": 5, + "type": 4, + "location": { + "file_path": "controls.py", + "start_line": 83, + "end_line": 106, + "start_col": 0, + "end_col": 19 + }, + "hash": "812b0f465b91100c", + "size": 71, + "line_count": 24, + "complexity": 0 + }, + { + "id": 6, + "type": 4, + "location": { + "file_path": "controls.py", + "start_line": 109, + "end_line": 132, + "start_col": 0, + "end_col": 17 + }, + "hash": "7480c2c7d52aea57", + "size": 76, + "line_count": 24, + "complexity": 0 + }, + { + "id": 7, + "type": 4, + "location": { + "file_path": "controls.py", + "start_line": 135, + "end_line": 156, + "start_col": 0, + "end_col": 18 + }, + "hash": "f098bd92815387c7", + "size": 39, + "line_count": 22, + "complexity": 0 + }, + { + "id": 10, + "type": 4, + "location": { + "file_path": "controls.py", + "start_line": 159, + "end_line": 178, + "start_col": 0, + "end_col": 18 + }, + "hash": "853f2860c0528969", + "size": 42, + "line_count": 20, + "complexity": 0 + }, + { + "id": 11, + "type": 4, + "location": { + "file_path": "pricing.py", + "start_line": 5, + "end_line": 29, + "start_col": 0, + "end_col": 26 + }, + "hash": "8fba7e04f95f6684", + "size": 70, + "line_count": 25, + "complexity": 0 + }, + { + "id": 13, + "type": 4, + "location": { + "file_path": "pricing.py", + "start_line": 32, + "end_line": 60, + "start_col": 0, + "end_col": 19 + }, + "hash": "c3a002abcb35b813", + "size": 80, + "line_count": 29, + "complexity": 0 + }, + { + "id": 15, + "type": 4, + "location": { + "file_path": "pricing.py", + "start_line": 63, + "end_line": 87, + "start_col": 0, + "end_col": 81 + }, + "hash": "772b6585164605b1", + "size": 79, + "line_count": 25, + "complexity": 0 + }, + { + "id": 17, + "type": 4, + "location": { + "file_path": "pricing.py", + "start_line": 90, + "end_line": 113, + "start_col": 0, + "end_col": 38 + }, + "hash": "bcc9f0dcbc803396", + "size": 66, + "line_count": 24, + "complexity": 0 + }, + { + "id": 19, + "type": 4, + "location": { + "file_path": "pricing.py", + "start_line": 116, + "end_line": 143, + "start_col": 0, + "end_col": 23 + }, + "hash": "cd9ba282f4165c21", + "size": 87, + "line_count": 28, + "complexity": 0 + }, + { + "id": 21, + "type": 4, + "location": { + "file_path": "reporting.py", + "start_line": 5, + "end_line": 30, + "start_col": 0, + "end_col": 26 + }, + "hash": "3127400b0a7e04ee", + "size": 70, + "line_count": 26, + "complexity": 0 + }, + { + "id": 23, + "type": 4, + "location": { + "file_path": "reporting.py", + "start_line": 33, + "end_line": 62, + "start_col": 0, + "end_col": 17 + }, + "hash": "e4f15bd97a8390a8", + "size": 80, + "line_count": 30, + "complexity": 0 + }, + { + "id": 25, + "type": 4, + "location": { + "file_path": "reporting.py", + "start_line": 65, + "end_line": 97, + "start_col": 0, + "end_col": 5 + }, + "hash": "c643879f2970038e", + "size": 88, + "line_count": 33, + "complexity": 0 + }, + { + "id": 27, + "type": 4, + "location": { + "file_path": "reporting.py", + "start_line": 100, + "end_line": 122, + "start_col": 0, + "end_col": 39 + }, + "hash": "0350058c0ceb64d2", + "size": 70, + "line_count": 23, + "complexity": 0 + }, + { + "id": 28, + "type": 4, + "location": { + "file_path": "reporting.py", + "start_line": 125, + "end_line": 153, + "start_col": 0, + "end_col": 21 + }, + "hash": "2ef9baa88015cfa1", + "size": 87, + "line_count": 29, + "complexity": 0 + } + ], + "type": 4, + "similarity": 0.7847222747406449, + "size": 15 + } + ], + "statistics": { + "total_fragments": 29, + "total_clones": 15, + "total_clone_pairs": 20, + "total_clone_groups": 1, + "clones_by_type": { + "Type-2": 3, + "Type-3": 1, + "Type-4": 16 + }, + "average_similarity": 0.784722274740645, + "lines_analyzed": 483, + "nodes_analyzed": 1194, + "files_analyzed": 4 + }, + "request": { + "paths": [ + "__init__.py", + "controls.py", + "pricing.py", + "reporting.py" + ], + "recursive": true, + "include_patterns": [ + "**/*.py" + ], + "exclude_patterns": [ + "test_*.py", + "*_test.py", + "**/tests/**", + "**/test/**", + "**/testing/**", + "**/migrations/**" + ], + "min_lines": 5, + "min_nodes": 10, + "similarity_threshold": 0.65, + "max_edit_distance": 50, + "ignore_literals": false, + "ignore_identifiers": false, + "skip_docstrings": true, + "type1_threshold": 0.85, + "type2_threshold": 0.75, + "type3_threshold": 0.7, + "type4_threshold": 0.65, + "enable_dfa": true, + "output_format": "json", + "output_path": "", + "no_open": false, + "show_details": false, + "show_content": false, + "sort_by": "similarity", + "group_clones": true, + "group_mode": "connected", + "group_threshold": 0.65, + "k_core_k": 2, + "min_similarity": 0, + "max_similarity": 1, + "clone_types": [ + 1, + 2, + 3, + 4 + ], + "config_path": "../configs/pyscn-permissive.toml", + "timeout": 0, + "lsh_enabled": "auto", + "lsh_auto_threshold": 500, + "lsh_similarity_threshold": 0.5, + "lsh_bands": 32, + "lsh_rows": 4, + "lsh_hashes": 128 + }, + "duration_ms": 88, + "success": true + }, + "suggestions": [ + { + "category": "clone", + "severity": "critical", + "effort": "hard", + "title": "Extract duplicated code (Type-4 semantic, 15 fragments)", + "description": "Semantically similar code with different structure. Evaluate whether a common abstraction is appropriate.", + "steps": [ + "Analyze whether the 15 fragments serve the same purpose", + "If so, design a common abstraction (base class, strategy pattern, or utility)", + "Refactor incrementally — start with 2 fragments, then extend" + ], + "file_path": "controls.py", + "start_line": 45, + "metric_value": "78% similarity" + } + ], + "summary": { + "total_files": 0, + "analyzed_files": 0, + "skipped_files": 0, + "complexity_enabled": false, + "dead_code_enabled": false, + "clone_enabled": true, + "cbo_enabled": false, + "mock_data_enabled": false, + "deps_enabled": false, + "arch_enabled": false, + "communities_enabled": false, + "deps_total_modules": 0, + "deps_modules_in_cycles": 0, + "deps_max_depth": 0, + "deps_main_sequence_deviation": 0, + "arch_compliance": 0, + "community_count": 0, + "community_modularity": 0, + "community_bridge_modules": 0, + "community_internal_edges": 0, + "community_cross_edges": 0, + "total_functions": 0, + "functions_parsed": 0, + "average_complexity": 0, + "average_cognitive_complexity": 0, + "average_nesting_depth": 0, + "high_complexity_count": 0, + "dead_code_count": 0, + "critical_dead_code": 0, + "warning_dead_code": 0, + "info_dead_code": 0, + "total_clones": 15, + "clone_pairs": 20, + "clone_groups": 1, + "code_duplication_percentage": 30, + "cbo_classes": 0, + "high_coupling_classes": 0, + "medium_coupling_classes": 0, + "average_coupling": 0, + "lcom_enabled": false, + "lcom_classes": 0, + "high_lcom_classes": 0, + "medium_lcom_classes": 0, + "average_lcom": 0, + "mock_data_count": 0, + "mock_data_error_count": 0, + "mock_data_warning_count": 0, + "mock_data_info_count": 0, + "health_score": 80, + "grade": "B", + "complexity_score": 100, + "dead_code_score": 100, + "duplication_score": 0, + "coupling_score": 100, + "cohesion_score": 100, + "dependency_score": 100, + "architecture_score": 0, + "community_score": 100, + "community_risk_score": 0 + }, + "generated_at": "2026-08-22T16:11:00.145653727+01:00", + "duration_ms": 89, + "version": "1.29.1" +} diff --git a/benchmarks/duplication/results/scores.json b/benchmarks/duplication/results/scores.json new file mode 100644 index 00000000..b5bbe9e2 --- /dev/null +++ b/benchmarks/duplication/results/scores.json @@ -0,0 +1,63 @@ +{ + "date": "2026-08-22", + "oracle_expectations": 10, + "tools": { + "pyscn": { + "version": "1.29.1", + "raw_findings": 20, + "lanes": { + "syntactic-clone": { + "tp": 4, + "fp": 1, + "fn": 0, + "tn": 2, + "unmatched_findings": 0, + "precision": 0.8, + "recall": 1.0, + "f1": 0.888888888888889 + }, + "semantic-clone": { + "tp": 1, + "fp": 0, + "fn": 0, + "tn": 2, + "unmatched_findings": 14, + "precision": 1.0, + "recall": 1.0, + "f1": 1.0 + } + }, + "corpus_elapsed_ms": 230, + "episodic_elapsed_ms": 1000 + }, + "pychase": { + "version": "0.1.0", + "raw_findings": 5, + "lanes": { + "syntactic-clone": { + "tp": 4, + "fp": 0, + "fn": 0, + "tn": 3, + "unmatched_findings": 0, + "precision": 1.0, + "recall": 1.0, + "f1": 1.0 + }, + "semantic-clone": { + "tp": 0, + "fp": 0, + "fn": 1, + "tn": 2, + "unmatched_findings": 0, + "precision": null, + "recall": 0.0, + "f1": null + } + }, + "corpus_elapsed_ms": 140, + "episodic_elapsed_ms": 4800 + } + }, + "notes": "Corpus runs used the permissive capability settings recorded in the benchmark README. Elapsed times are single wall-clock observations, not performance benchmarks." +} diff --git a/benchmarks/duplication/results/tuning-generation1-pychase.json b/benchmarks/duplication/results/tuning-generation1-pychase.json new file mode 100644 index 00000000..347affe4 --- /dev/null +++ b/benchmarks/duplication/results/tuning-generation1-pychase.json @@ -0,0 +1,2648 @@ +[ + { + "cfg": { + "threshold": 0.6, + "min_lines": 4, + "min_nodes": 10, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 4, + "min_nodes": 10, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 4, + "min_nodes": 10, + "shingle_size": 4 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 4, + "min_nodes": 20, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 4, + "min_nodes": 20, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 4, + "min_nodes": 20, + "shingle_size": 4 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 4, + "min_nodes": 30, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 4, + "min_nodes": 30, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 4, + "min_nodes": 30, + "shingle_size": 4 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 6, + "min_nodes": 10, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 6, + "min_nodes": 10, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 6, + "min_nodes": 10, + "shingle_size": 4 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 6, + "min_nodes": 20, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 6, + "min_nodes": 20, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 6, + "min_nodes": 20, + "shingle_size": 4 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 6, + "min_nodes": 30, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 6, + "min_nodes": 30, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 6, + "min_nodes": 30, + "shingle_size": 4 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 8, + "min_nodes": 10, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 8, + "min_nodes": 10, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 8, + "min_nodes": 10, + "shingle_size": 4 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 8, + "min_nodes": 20, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 8, + "min_nodes": 20, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 8, + "min_nodes": 20, + "shingle_size": 4 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 8, + "min_nodes": 30, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 8, + "min_nodes": 30, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.6, + "min_lines": 8, + "min_nodes": 30, + "shingle_size": 4 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 4, + "min_nodes": 10, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 4, + "min_nodes": 10, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 4, + "min_nodes": 10, + "shingle_size": 4 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 4, + "min_nodes": 20, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 4, + "min_nodes": 20, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 4, + "min_nodes": 20, + "shingle_size": 4 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 4, + "min_nodes": 30, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 4, + "min_nodes": 30, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 4, + "min_nodes": 30, + "shingle_size": 4 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 6, + "min_nodes": 10, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 6, + "min_nodes": 10, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 6, + "min_nodes": 10, + "shingle_size": 4 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 6, + "min_nodes": 20, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 6, + "min_nodes": 20, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 6, + "min_nodes": 20, + "shingle_size": 4 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 6, + "min_nodes": 30, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 6, + "min_nodes": 30, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 6, + "min_nodes": 30, + "shingle_size": 4 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 8, + "min_nodes": 10, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 8, + "min_nodes": 10, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 8, + "min_nodes": 10, + "shingle_size": 4 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 8, + "min_nodes": 20, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 8, + "min_nodes": 20, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 8, + "min_nodes": 20, + "shingle_size": 4 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 8, + "min_nodes": 30, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 8, + "min_nodes": 30, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.7, + "min_lines": 8, + "min_nodes": 30, + "shingle_size": 4 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 4, + "min_nodes": 10, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 4, + "min_nodes": 10, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 4, + "min_nodes": 20, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 4, + "min_nodes": 20, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 4, + "min_nodes": 30, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 4, + "min_nodes": 30, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 6, + "min_nodes": 10, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 6, + "min_nodes": 10, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 6, + "min_nodes": 20, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 6, + "min_nodes": 20, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 6, + "min_nodes": 30, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 6, + "min_nodes": 30, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 8, + "min_nodes": 10, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 8, + "min_nodes": 10, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 8, + "min_nodes": 20, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 8, + "min_nodes": 20, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 8, + "min_nodes": 30, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 8, + "min_nodes": 30, + "shingle_size": 3 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 4, + "min_nodes": 10, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 4, + "min_nodes": 20, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 4, + "min_nodes": 30, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 6, + "min_nodes": 10, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 6, + "min_nodes": 20, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 6, + "min_nodes": 30, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 8, + "min_nodes": 10, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 8, + "min_nodes": 20, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 8, + "min_nodes": 30, + "shingle_size": 2 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 4, + "min_nodes": 10, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 4, + "min_nodes": 20, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 4, + "min_nodes": 30, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 6, + "min_nodes": 10, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 6, + "min_nodes": 20, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 6, + "min_nodes": 30, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 8, + "min_nodes": 10, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 8, + "min_nodes": 20, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.75, + "min_lines": 8, + "min_nodes": 30, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 4, + "min_nodes": 10, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 4, + "min_nodes": 10, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 4, + "min_nodes": 20, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 4, + "min_nodes": 20, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 4, + "min_nodes": 30, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 4, + "min_nodes": 30, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 6, + "min_nodes": 10, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 6, + "min_nodes": 10, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 6, + "min_nodes": 20, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 6, + "min_nodes": 20, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 6, + "min_nodes": 30, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 6, + "min_nodes": 30, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 8, + "min_nodes": 10, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 8, + "min_nodes": 10, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 8, + "min_nodes": 20, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 8, + "min_nodes": 20, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 8, + "min_nodes": 30, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.8, + "min_lines": 8, + "min_nodes": 30, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 4, + "min_nodes": 10, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 4, + "min_nodes": 10, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 4, + "min_nodes": 10, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 4, + "min_nodes": 20, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 4, + "min_nodes": 20, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 4, + "min_nodes": 20, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 4, + "min_nodes": 30, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 4, + "min_nodes": 30, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 4, + "min_nodes": 30, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 6, + "min_nodes": 10, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 6, + "min_nodes": 10, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 6, + "min_nodes": 10, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 6, + "min_nodes": 20, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 6, + "min_nodes": 20, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 6, + "min_nodes": 20, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 6, + "min_nodes": 30, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 6, + "min_nodes": 30, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 6, + "min_nodes": 30, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 8, + "min_nodes": 10, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 8, + "min_nodes": 10, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 8, + "min_nodes": 10, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 8, + "min_nodes": 20, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 8, + "min_nodes": 20, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 8, + "min_nodes": 20, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 8, + "min_nodes": 30, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 8, + "min_nodes": 30, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.82, + "min_lines": 8, + "min_nodes": 30, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 4, + "min_nodes": 10, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 4, + "min_nodes": 10, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 4, + "min_nodes": 10, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 4, + "min_nodes": 20, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 4, + "min_nodes": 20, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 4, + "min_nodes": 20, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 4, + "min_nodes": 30, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 4, + "min_nodes": 30, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 4, + "min_nodes": 30, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 6, + "min_nodes": 10, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 6, + "min_nodes": 10, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 6, + "min_nodes": 10, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 6, + "min_nodes": 20, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 6, + "min_nodes": 20, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 6, + "min_nodes": 20, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 6, + "min_nodes": 30, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 6, + "min_nodes": 30, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 6, + "min_nodes": 30, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 8, + "min_nodes": 10, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 8, + "min_nodes": 10, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 8, + "min_nodes": 10, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 8, + "min_nodes": 20, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 8, + "min_nodes": 20, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 8, + "min_nodes": 20, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 8, + "min_nodes": 30, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 8, + "min_nodes": 30, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.85, + "min_lines": 8, + "min_nodes": 30, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 4, + "min_nodes": 10, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 4, + "min_nodes": 10, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 4, + "min_nodes": 10, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 4, + "min_nodes": 20, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 4, + "min_nodes": 20, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 4, + "min_nodes": 20, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 4, + "min_nodes": 30, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 4, + "min_nodes": 30, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 4, + "min_nodes": 30, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 6, + "min_nodes": 10, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 6, + "min_nodes": 10, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 6, + "min_nodes": 10, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 6, + "min_nodes": 20, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 6, + "min_nodes": 20, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 6, + "min_nodes": 20, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 6, + "min_nodes": 30, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 6, + "min_nodes": 30, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 6, + "min_nodes": 30, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 8, + "min_nodes": 10, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 8, + "min_nodes": 10, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 8, + "min_nodes": 10, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 8, + "min_nodes": 20, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 8, + "min_nodes": 20, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 8, + "min_nodes": 20, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 8, + "min_nodes": 30, + "shingle_size": 2 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 8, + "min_nodes": 30, + "shingle_size": 3 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "threshold": 0.9, + "min_lines": 8, + "min_nodes": 30, + "shingle_size": 4 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + } +] \ No newline at end of file diff --git a/benchmarks/duplication/results/tuning-generation1-pyscn.json b/benchmarks/duplication/results/tuning-generation1-pyscn.json new file mode 100644 index 00000000..c07a6812 --- /dev/null +++ b/benchmarks/duplication/results/tuning-generation1-pyscn.json @@ -0,0 +1,1406 @@ +[ + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.65, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.7, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.65, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.7, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 4, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.8, + "f1": 0.889 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.85, + "type4_threshold": 0.72, + "type3_threshold": 0.77, + "type2_threshold": 0.82, + "type1_threshold": 0.92, + "similarity_threshold": 0.72 + }, + "tp": 4, + "fp": 1, + "unmatched": 0, + "precision": 0.8, + "recall": 0.8, + "f1": 0.8 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.85, + "type4_threshold": 0.8, + "type3_threshold": 0.85, + "type2_threshold": 0.9, + "type1_threshold": 1.0, + "similarity_threshold": 0.8 + }, + "tp": 4, + "fp": 1, + "unmatched": 0, + "precision": 0.8, + "recall": 0.8, + "f1": 0.8 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.85, + "type4_threshold": 0.72, + "type3_threshold": 0.77, + "type2_threshold": 0.82, + "type1_threshold": 0.92, + "similarity_threshold": 0.72 + }, + "tp": 4, + "fp": 1, + "unmatched": 0, + "precision": 0.8, + "recall": 0.8, + "f1": 0.8 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.85, + "type4_threshold": 0.8, + "type3_threshold": 0.85, + "type2_threshold": 0.9, + "type1_threshold": 1.0, + "similarity_threshold": 0.8 + }, + "tp": 4, + "fp": 1, + "unmatched": 0, + "precision": 0.8, + "recall": 0.8, + "f1": 0.8 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.65, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.7, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.75, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.8, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.75, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.8, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.65, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.7, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.75, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.8, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.75, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.8, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.65, + "type4_threshold": 0.8, + "type3_threshold": 0.85, + "type2_threshold": 0.9, + "type1_threshold": 1.0, + "similarity_threshold": 0.8 + }, + "tp": 4, + "fp": 1, + "unmatched": 3, + "precision": 0.5, + "recall": 0.8, + "f1": 0.615 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.7, + "type4_threshold": 0.8, + "type3_threshold": 0.85, + "type2_threshold": 0.9, + "type1_threshold": 1.0, + "similarity_threshold": 0.8 + }, + "tp": 4, + "fp": 1, + "unmatched": 3, + "precision": 0.5, + "recall": 0.8, + "f1": 0.615 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.75, + "type4_threshold": 0.8, + "type3_threshold": 0.85, + "type2_threshold": 0.9, + "type1_threshold": 1.0, + "similarity_threshold": 0.8 + }, + "tp": 4, + "fp": 1, + "unmatched": 3, + "precision": 0.5, + "recall": 0.8, + "f1": 0.615 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.8, + "type4_threshold": 0.72, + "type3_threshold": 0.77, + "type2_threshold": 0.82, + "type1_threshold": 0.92, + "similarity_threshold": 0.72 + }, + "tp": 4, + "fp": 1, + "unmatched": 3, + "precision": 0.5, + "recall": 0.8, + "f1": 0.615 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.8, + "type4_threshold": 0.8, + "type3_threshold": 0.85, + "type2_threshold": 0.9, + "type1_threshold": 1.0, + "similarity_threshold": 0.8 + }, + "tp": 4, + "fp": 1, + "unmatched": 3, + "precision": 0.5, + "recall": 0.8, + "f1": 0.615 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.65, + "type4_threshold": 0.8, + "type3_threshold": 0.85, + "type2_threshold": 0.9, + "type1_threshold": 1.0, + "similarity_threshold": 0.8 + }, + "tp": 4, + "fp": 1, + "unmatched": 3, + "precision": 0.5, + "recall": 0.8, + "f1": 0.615 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.7, + "type4_threshold": 0.8, + "type3_threshold": 0.85, + "type2_threshold": 0.9, + "type1_threshold": 1.0, + "similarity_threshold": 0.8 + }, + "tp": 4, + "fp": 1, + "unmatched": 3, + "precision": 0.5, + "recall": 0.8, + "f1": 0.615 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.75, + "type4_threshold": 0.8, + "type3_threshold": 0.85, + "type2_threshold": 0.9, + "type1_threshold": 1.0, + "similarity_threshold": 0.8 + }, + "tp": 4, + "fp": 1, + "unmatched": 3, + "precision": 0.5, + "recall": 0.8, + "f1": 0.615 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.8, + "type4_threshold": 0.72, + "type3_threshold": 0.77, + "type2_threshold": 0.82, + "type1_threshold": 0.92, + "similarity_threshold": 0.72 + }, + "tp": 4, + "fp": 1, + "unmatched": 3, + "precision": 0.5, + "recall": 0.8, + "f1": 0.615 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.8, + "type4_threshold": 0.8, + "type3_threshold": 0.85, + "type2_threshold": 0.9, + "type1_threshold": 1.0, + "similarity_threshold": 0.8 + }, + "tp": 4, + "fp": 1, + "unmatched": 3, + "precision": 0.5, + "recall": 0.8, + "f1": 0.615 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.75, + "type4_threshold": 0.72, + "type3_threshold": 0.77, + "type2_threshold": 0.82, + "type1_threshold": 0.92, + "similarity_threshold": 0.72 + }, + "tp": 5, + "fp": 1, + "unmatched": 8, + "precision": 0.357, + "recall": 1.0, + "f1": 0.526 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.75, + "type4_threshold": 0.72, + "type3_threshold": 0.77, + "type2_threshold": 0.82, + "type1_threshold": 0.92, + "similarity_threshold": 0.72 + }, + "tp": 5, + "fp": 1, + "unmatched": 8, + "precision": 0.357, + "recall": 1.0, + "f1": 0.526 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.9, + "type4_threshold": 0.72, + "type3_threshold": 0.77, + "type2_threshold": 0.82, + "type1_threshold": 0.92, + "similarity_threshold": 0.72 + }, + "tp": 2, + "fp": 1, + "unmatched": 0, + "precision": 0.667, + "recall": 0.4, + "f1": 0.5 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.9, + "type4_threshold": 0.8, + "type3_threshold": 0.85, + "type2_threshold": 0.9, + "type1_threshold": 1.0, + "similarity_threshold": 0.8 + }, + "tp": 2, + "fp": 1, + "unmatched": 0, + "precision": 0.667, + "recall": 0.4, + "f1": 0.5 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.9, + "type4_threshold": 0.72, + "type3_threshold": 0.77, + "type2_threshold": 0.82, + "type1_threshold": 0.92, + "similarity_threshold": 0.72 + }, + "tp": 2, + "fp": 1, + "unmatched": 0, + "precision": 0.667, + "recall": 0.4, + "f1": 0.5 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.9, + "type4_threshold": 0.8, + "type3_threshold": 0.85, + "type2_threshold": 0.9, + "type1_threshold": 1.0, + "similarity_threshold": 0.8 + }, + "tp": 2, + "fp": 1, + "unmatched": 0, + "precision": 0.667, + "recall": 0.4, + "f1": 0.5 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.8, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 3, + "fp": 1, + "unmatched": 3, + "precision": 0.429, + "recall": 0.6, + "f1": 0.5 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.8, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 3, + "fp": 1, + "unmatched": 3, + "precision": 0.429, + "recall": 0.6, + "f1": 0.5 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.75, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 4, + "fp": 1, + "unmatched": 8, + "precision": 0.308, + "recall": 0.8, + "f1": 0.444 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.75, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 4, + "fp": 1, + "unmatched": 8, + "precision": 0.308, + "recall": 0.8, + "f1": 0.444 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.65, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 5, + "fp": 1, + "unmatched": 14, + "precision": 0.25, + "recall": 1.0, + "f1": 0.4 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.65, + "type4_threshold": 0.72, + "type3_threshold": 0.77, + "type2_threshold": 0.82, + "type1_threshold": 0.92, + "similarity_threshold": 0.72 + }, + "tp": 5, + "fp": 1, + "unmatched": 14, + "precision": 0.25, + "recall": 1.0, + "f1": 0.4 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.7, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 5, + "fp": 1, + "unmatched": 14, + "precision": 0.25, + "recall": 1.0, + "f1": 0.4 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.7, + "type4_threshold": 0.72, + "type3_threshold": 0.77, + "type2_threshold": 0.82, + "type1_threshold": 0.92, + "similarity_threshold": 0.72 + }, + "tp": 5, + "fp": 1, + "unmatched": 14, + "precision": 0.25, + "recall": 1.0, + "f1": 0.4 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.65, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 5, + "fp": 1, + "unmatched": 14, + "precision": 0.25, + "recall": 1.0, + "f1": 0.4 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.65, + "type4_threshold": 0.72, + "type3_threshold": 0.77, + "type2_threshold": 0.82, + "type1_threshold": 0.92, + "similarity_threshold": 0.72 + }, + "tp": 5, + "fp": 1, + "unmatched": 14, + "precision": 0.25, + "recall": 1.0, + "f1": 0.4 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.7, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 5, + "fp": 1, + "unmatched": 14, + "precision": 0.25, + "recall": 1.0, + "f1": 0.4 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.7, + "type4_threshold": 0.72, + "type3_threshold": 0.77, + "type2_threshold": 0.82, + "type1_threshold": 0.92, + "similarity_threshold": 0.72 + }, + "tp": 5, + "fp": 1, + "unmatched": 14, + "precision": 0.25, + "recall": 1.0, + "f1": 0.4 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.85, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.9, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.85, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.9, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.85, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 0, + "fp": 1, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + { + "cfg": { + "min_lines": 5, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.9, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 0, + "fp": 1, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.85, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.9, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.85, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.9, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.85, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 0, + "fp": 1, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + { + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3", + "type4" + ], + "min_similarity": 0.9, + "type4_threshold": 0.65, + "type3_threshold": 0.7, + "type2_threshold": 0.75, + "type1_threshold": 0.85, + "similarity_threshold": 0.65 + }, + "tp": 0, + "fp": 1, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + } +] \ No newline at end of file diff --git a/benchmarks/duplication/results/tuning-generation3-production.json b/benchmarks/duplication/results/tuning-generation3-production.json new file mode 100644 index 00000000..38e47258 --- /dev/null +++ b/benchmarks/duplication/results/tuning-generation3-production.json @@ -0,0 +1,808 @@ +[ + { + "tool": "pychase", + "cfg": { + "threshold": 0.9, + "min_lines": 13, + "min_nodes": 50 + }, + "selection_rationale": "Selected between the recorded min_lines=10 and min_lines=15 plateaus: 10 kept the same corpus score but produced 121 candidates, while 15 reduced the scan to 52 at the risk of excluding gate-relevant 13-14-line copies. The 13-line setting retained the corpus score and produced the adjudicated 84-candidate production scan; see docs/pychase-pyscn-duplication-head-to-head.md#configuration-tuning.", + "corpus": { + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + "episodic": 84 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.8 + }, + "corpus": { + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + "episodic": 63 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.8 + }, + "corpus": { + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + "episodic": 64 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.85 + }, + "corpus": { + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + "episodic": 2 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.85 + }, + "corpus": { + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + "episodic": 2 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.9 + }, + "corpus": { + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + "episodic": 2 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.9 + }, + "corpus": { + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + "episodic": 2 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.95 + }, + "corpus": { + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + "episodic": 2 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 10, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.95 + }, + "corpus": { + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + "episodic": 2 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 15, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.8 + }, + "corpus": { + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + "episodic": 29 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 15, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.8 + }, + "corpus": { + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + "episodic": 30 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 15, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.85 + }, + "corpus": { + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + "episodic": 0 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 15, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.85 + }, + "corpus": { + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + "episodic": 0 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 15, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.9 + }, + "corpus": { + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + "episodic": 0 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 15, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.9 + }, + "corpus": { + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + "episodic": 0 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 15, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.95 + }, + "corpus": { + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + "episodic": 0 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 15, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.95 + }, + "corpus": { + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + "episodic": 0 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 20, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.8 + }, + "corpus": { + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + "episodic": 20 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 20, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.8 + }, + "corpus": { + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + "episodic": 21 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 20, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.85 + }, + "corpus": { + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + "episodic": 0 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 20, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.85 + }, + "corpus": { + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + "episodic": 0 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 20, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.9 + }, + "corpus": { + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + "episodic": 0 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 20, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.9 + }, + "corpus": { + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + "episodic": 0 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 20, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2" + ], + "min_similarity": 0.95 + }, + "corpus": { + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + "episodic": 0 + }, + { + "tool": "pyscn", + "cfg": { + "min_lines": 20, + "min_nodes": 10, + "enabled_clone_types": [ + "type1", + "type2", + "type3" + ], + "min_similarity": 0.95 + }, + "corpus": { + "tp": 0, + "fp": 0, + "unmatched": 0, + "precision": 0.0, + "recall": 0.0, + "f1": 0.0 + }, + "episodic": 0 + }, + { + "tool": "pychase", + "cfg": { + "threshold": 0.85, + "min_lines": 10, + "min_nodes": 30 + }, + "corpus": { + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + "episodic": 218 + }, + { + "tool": "pychase", + "cfg": { + "threshold": 0.85, + "min_lines": 10, + "min_nodes": 50 + }, + "corpus": { + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + "episodic": 138 + }, + { + "tool": "pychase", + "cfg": { + "threshold": 0.85, + "min_lines": 15, + "min_nodes": 30 + }, + "corpus": { + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + "episodic": 83 + }, + { + "tool": "pychase", + "cfg": { + "threshold": 0.85, + "min_lines": 15, + "min_nodes": 50 + }, + "corpus": { + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + "episodic": 60 + }, + { + "tool": "pychase", + "cfg": { + "threshold": 0.9, + "min_lines": 10, + "min_nodes": 30 + }, + "corpus": { + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + "episodic": 190 + }, + { + "tool": "pychase", + "cfg": { + "threshold": 0.9, + "min_lines": 10, + "min_nodes": 50 + }, + "corpus": { + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + "episodic": 121 + }, + { + "tool": "pychase", + "cfg": { + "threshold": 0.9, + "min_lines": 15, + "min_nodes": 30 + }, + "corpus": { + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + "episodic": 65 + }, + { + "tool": "pychase", + "cfg": { + "threshold": 0.9, + "min_lines": 15, + "min_nodes": 50 + }, + "corpus": { + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + "episodic": 52 + }, + { + "tool": "pychase", + "cfg": { + "threshold": 0.95, + "min_lines": 10, + "min_nodes": 30 + }, + "corpus": { + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + "episodic": 160 + }, + { + "tool": "pychase", + "cfg": { + "threshold": 0.95, + "min_lines": 10, + "min_nodes": 50 + }, + "corpus": { + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + "episodic": 97 + }, + { + "tool": "pychase", + "cfg": { + "threshold": 0.95, + "min_lines": 15, + "min_nodes": 30 + }, + "corpus": { + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + "episodic": 52 + }, + { + "tool": "pychase", + "cfg": { + "threshold": 0.95, + "min_lines": 15, + "min_nodes": 50 + }, + "corpus": { + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + "episodic": 40 + }, + { + "tool": "pychase", + "cfg": { + "threshold": 1.0, + "min_lines": 10, + "min_nodes": 30 + }, + "corpus": { + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + "episodic": 153 + }, + { + "tool": "pychase", + "cfg": { + "threshold": 1.0, + "min_lines": 10, + "min_nodes": 50 + }, + "corpus": { + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + "episodic": 90 + }, + { + "tool": "pychase", + "cfg": { + "threshold": 1.0, + "min_lines": 15, + "min_nodes": 30 + }, + "corpus": { + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + "episodic": 46 + }, + { + "tool": "pychase", + "cfg": { + "threshold": 1.0, + "min_lines": 15, + "min_nodes": 50 + }, + "corpus": { + "tp": 3, + "fp": 0, + "unmatched": 0, + "precision": 1.0, + "recall": 0.6, + "f1": 0.75 + }, + "episodic": 34 + } +] diff --git a/benchmarks/duplication/score.py b/benchmarks/duplication/score.py new file mode 100644 index 00000000..9a86df4e --- /dev/null +++ b/benchmarks/duplication/score.py @@ -0,0 +1,159 @@ +"""Score normalized clone-detector findings against labelled pairs. + +Detector-specific parsing lives in :mod:`benchmarks.duplication.parsers` and +the shared report-schema validation belongs to :mod:`benchmarks.score_support`. +This module owns only pair-specific matching and score accounting. +""" + +import typing as typ +from collections import ( + abc as cabc, # noqa: TC003 - annotations are evaluated on import by supported Python versions. +) + +from .models import Expectation, Fragment, Lane, LaneScore, PairFinding +from .parsers import parse_pychase_pairs, parse_pyscn_pairs + +__all__ = ( + "Expectation", + "Fragment", + "Lane", + "LaneScore", + "PairFinding", + "parse_pychase_pairs", + "parse_pyscn_pairs", + "score_findings", +) + +type _PairKey = tuple[tuple[str, int, int], tuple[str, int, int]] + + +class _MutableLaneCounts(typ.TypedDict): + """Mutable score counters for one benchmark lane.""" + + tp: int + fp: int + fn: int + tn: int + unmatched: int + + +type _LaneCounts = dict[Lane, _MutableLaneCounts] + + +def _finding_key(finding: PairFinding) -> _PairKey: + """Build an unordered location key identifying one reported pair.""" + members = sorted( + ( + (fragment.path, fragment.start_line, fragment.end_line) + for fragment in (finding.first, finding.second) + ), + ) + return (members[0], members[1]) + + +def _match_expectation( + finding: PairFinding, + expectations: cabc.Sequence[Expectation], +) -> Expectation | None: + """Return the labelled pair whose units the finding overlaps, if any.""" + for expectation in expectations: + direct = finding.first.overlaps(expectation.first) and finding.second.overlaps( + expectation.second + ) + swapped = finding.first.overlaps( + expectation.second + ) and finding.second.overlaps(expectation.first) + if direct or swapped: + return expectation + return None + + +def score_findings( + expectations: cabc.Sequence[Expectation], + findings: cabc.Sequence[PairFinding], +) -> cabc.Mapping[Lane, LaneScore]: + """Score unique reported pairs without discarding unmatched reports. + + Parameters + ---------- + expectations : collections.abc.Sequence[Expectation] + Tool-neutral duplication labels used as the scoring reference. + findings : collections.abc.Sequence[PairFinding] + Normalized detector reports to compare with the labels. + + Returns + ------- + collections.abc.Mapping[Lane, LaneScore] + Confusion-matrix scores and unmatched report counts for every lane. + """ + _reject_duplicate_expectations(expectations) + counts: _LaneCounts = { + lane: {"tp": 0, "fp": 0, "fn": 0, "tn": 0, "unmatched": 0} for lane in Lane + } + matched_identifiers = _score_unique_findings( + findings, + expectations=expectations, + counts=counts, + ) + for expectation in expectations: + if expectation.identifier in matched_identifiers: + continue + count_key = "fn" if expectation.is_clone else "tn" + counts[expectation.lane][count_key] += 1 + return { + lane: LaneScore( + true_positives=values["tp"], + false_positives=values["fp"], + false_negatives=values["fn"], + true_negatives=values["tn"], + unmatched_findings=values["unmatched"], + ) + for lane, values in counts.items() + } + + +def _reject_duplicate_expectations( + expectations: cabc.Sequence[Expectation], +) -> None: + """Reject ambiguous benchmark labels sharing an identifier or pair.""" + identifiers: set[str] = set() + pairs: set[_PairKey] = set() + for expectation in expectations: + members = sorted( + ( + (fragment.path, fragment.start_line, fragment.end_line) + for fragment in (expectation.first, expectation.second) + ), + ) + pair_key = (members[0], members[1]) + if expectation.identifier in identifiers or pair_key in pairs: + msg = f"duplicate expectation: {expectation.identifier}" + raise ValueError(msg) + identifiers.add(expectation.identifier) + pairs.add(pair_key) + + +def _score_unique_findings( + findings: cabc.Sequence[PairFinding], + *, + expectations: cabc.Sequence[Expectation], + counts: _LaneCounts, +) -> set[str]: + """Account for one report per pair and return matched label identifiers.""" + matched_identifiers: set[str] = set() + seen_pairs: set[_PairKey] = set() + for finding in findings: + key = _finding_key(finding) + if key in seen_pairs: + continue + seen_pairs.add(key) + expectation = _match_expectation(finding, expectations) + if expectation is None: + counts[finding.lane]["unmatched"] += 1 + continue + if expectation.identifier in matched_identifiers: + continue + count_key = "tp" if expectation.is_clone else "fp" + counts[expectation.lane][count_key] += 1 + matched_identifiers.add(expectation.identifier) + return matched_identifiers diff --git a/benchmarks/score_support.py b/benchmarks/score_support.py new file mode 100644 index 00000000..ebf27d60 --- /dev/null +++ b/benchmarks/score_support.py @@ -0,0 +1,178 @@ +"""Shared, low-level validation primitives for benchmark report parsers. + +Both benchmark suites normalize untrusted detector JSON before scoring. Keep +only schema and path validation here; detector-specific parsing and scoring +remain in their benchmark packages. Each helper returns a normalized value or +raises a precise exception at the report boundary, keeping later scoring code +free of repeated shape checks. +""" + +import typing as typ +from collections import abc as cabc +from pathlib import Path + + +def mapping(value: object, *, context: str) -> cabc.Mapping[str, object]: + """Validate and return a string-keyed JSON object. + + Parameters + ---------- + value : object + Candidate decoded JSON value. + context : str + Human-readable path used in validation messages. + + Returns + ------- + collections.abc.Mapping[str, object] + The validated object with string keys. + + Raises + ------ + TypeError + If ``value`` is not an object or has a non-string key. + """ + if not isinstance(value, cabc.Mapping): + msg = f"{context} must be a JSON object" + raise TypeError(msg) + if not all(isinstance(key, str) for key in value): + msg = f"{context} keys must be strings" + raise TypeError(msg) + return typ.cast("cabc.Mapping[str, object]", value) + + +def sequence( + value: object, + *, + context: str, + none_is_empty: bool = False, +) -> cabc.Sequence[object]: + """Validate and return a non-string JSON array. + + Parameters + ---------- + value : object + Candidate decoded JSON value. + context : str + Human-readable path used in validation messages. + none_is_empty : bool, default=False + Treat ``None`` as an empty sequence when a detector uses null for no + results. + + Returns + ------- + collections.abc.Sequence[object] + The validated sequence, or an empty tuple for an allowed ``None``. + + Raises + ------ + TypeError + If the value is not a non-string sequence. + """ + if value is None and none_is_empty: + return () + if not isinstance(value, cabc.Sequence) or isinstance(value, (str, bytes)): + msg = f"{context} must be a JSON array" + raise TypeError(msg) + return value + + +def string(value: object, *, context: str) -> str: + """Validate and return a string report field. + + Parameters + ---------- + value : object + Candidate decoded JSON value. + context : str + Human-readable field path used in the error message. + + Returns + ------- + str + The validated string. + + Raises + ------ + TypeError + If ``value`` is not a string. + """ + if not isinstance(value, str): + msg = f"{context} must be a string" + raise TypeError(msg) + return value + + +def positive_line(value: object, *, context: str) -> int: + """Validate and return a positive, non-boolean line number. + + Parameters + ---------- + value : object + Candidate decoded JSON value. + context : str + Human-readable field path used in the error message. + + Returns + ------- + int + A line number greater than or equal to one. + + Raises + ------ + TypeError + If ``value`` is not an integer or is a boolean. + ValueError + If the integer is less than one. + """ + if not isinstance(value, int) or isinstance(value, bool): + msg = f"{context} must be a positive integer" + raise TypeError(msg) + if value < 1: + msg = f"{context} must be positive" + raise ValueError(msg) + return value + + +def relative_source_path( + raw_path: object, + corpus_root: Path, + *, + subject: str, +) -> str: + """Normalize a source path relative to the corpus root. + + Parameters + ---------- + raw_path : object + Candidate detector path, expected to be a string. + corpus_root : pathlib.Path + Root directory against which relative paths are resolved. + subject : str + Name of the report object used in validation messages. + + Returns + ------- + str + A normalized POSIX path relative to ``corpus_root``. + + Notes + ----- + The :class:`TypeError` raised by :func:`string` is propagated when + ``raw_path`` is not a string. + + Raises + ------ + ValueError + If the resolved path escapes ``corpus_root``. + """ + root = corpus_root.resolve() + path = Path(string(raw_path, context=f"{subject} path")) + if not path.is_absolute(): + path = root / path + path = path.resolve() + try: + return path.relative_to(root).as_posix() + except ValueError as error: + msg = f"{subject} path {path} is outside corpus root {root}" + raise ValueError(msg) from error diff --git a/docs/adr/adr-016-adopt-skylos-dead-code-detection.md b/docs/adr/adr-016-adopt-skylos-dead-code-detection.md index bb4c0beb..ed66cbd0 100644 --- a/docs/adr/adr-016-adopt-skylos-dead-code-detection.md +++ b/docs/adr/adr-016-adopt-skylos-dead-code-detection.md @@ -21,8 +21,8 @@ maintainers must remove when they become stale. - `make lint` runs Skylos locally with concise output and fails while unsuppressed dead-code findings remain. - Contributors remove genuine dead code and use - `make skylos-allow NAME=... REASON=...` only for intentional named - exceptions; the target rejects missing names or reasons. + `make skylos-allow SYMBOL=... REASON=...` only for intentional named + exceptions; the target rejects missing SYMBOL values or reasons. - Framework callbacks, protocol implementations, and compatibility re-exports remain live through precise, reasoned configuration rather than bulk baselines or unexplained inline suppressions. diff --git a/docs/adr/adr-018-adopt-pychase-duplication-gate.md b/docs/adr/adr-018-adopt-pychase-duplication-gate.md new file mode 100644 index 00000000..db78d172 --- /dev/null +++ b/docs/adr/adr-018-adopt-pychase-duplication-gate.md @@ -0,0 +1,40 @@ +# ADR-018: Adopt PyChase code-duplication gate + +- Status: Accepted +- Date: 2026-08-22 +- Deciders: Episodic maintainers + +## Context and decision + +In the context of keeping copy-paste duplication out of Episodic, facing the +benchmark result that PyChase 0.1.0 detects Type 1-3 clones with perfect corpus +precision while pyscn 1.29.1 cannot separate true semantic clones from false +ones, the decision is to use a local, blocking PyChase scan driven by +`scripts/duplication_gate.py` in `make lint`, with tuned thresholds and +documented declarative-module exclusions in `[tool.pychase]`, and reasoned unit +or pair exceptions in `[tool.duplication_gate]`, and against an advisory-only +report, pyscn's clone analysis, semantic (Type-4) enforcement, inline pragma +suppression, or unexplained baselines, to achieve deterministic duplication +enforcement whose exceptions remain reviewable in version control, accepting +that the gate pins Python 3.13 and a fixed `PYTHONHASHSEED` for the detector's +sake, that declarative modules are excluded wholesale rather than per finding, +and that semantic duplication remains a human review concern. + +## Consequences + +- `make lint` (and the standalone `make duplication` target) runs the gate + and fails while unsuppressed duplicate pairs remain, printing + `path:lines ~ path:lines` findings with qualified unit names that feed + directly into refactoring work. +- Contributors extract the shared logic, or record a considered exception + with `make duplication-allow FIRST=... [SECOND=...] REASON=...`; the target + rejects missing keys or reasons, and the gate reports entries whose + duplication has been resolved as stale so they get removed. +- Storage record models, record/domain mappers, repository protocols, and + typed request/response modules are excluded by documented patterns because + identifier normalization makes such declarations structurally identical + without any copy-paste. +- The benchmark corpus, tuning tables, and production adjudication that + justify the tool choice and thresholds are retained under + `benchmarks/duplication/` and summarized in + [the duplication head-to-head](../pychase-pyscn-duplication-head-to-head.md). diff --git a/docs/contents.md b/docs/contents.md index 8c013ee6..826a75ad 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -52,6 +52,8 @@ or delivery planning. - [pyscn and Skylos dead-code detection head-to-head](pyscn-skylos-dead-code-head-to-head.md) - measured comparison of unused-symbol and unreachable-statement detection. +- [PyChase and pyscn code-duplication head-to-head](pychase-pyscn-duplication-head-to-head.md) + - measured comparison of clone detection and the duplication-gate tuning. - [Agentic systems with LangGraph and Celery](agentic-systems-with-langgraph-and-celery.md) - background reference for agentic workflow orchestration. - [Cost management in LangGraph agentic systems](cost-management-in-langgraph-agentic-systems.md) @@ -104,6 +106,8 @@ or delivery planning. - [ADR 017: No-QA generation execution and TEI persistence][adr-017] - generation launcher, draft persistence, recovery, and TEI retrieval decisions. +- [ADR 018: Adopt PyChase duplication gate](adr/adr-018-adopt-pychase-duplication-gate.md) + - blocking code-duplication detection and exception policy. [adr-017]: adr/adr-017-no-qa-generation-run-execution-and-tei-persistence.md diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 81317eb9..af33267f 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -38,6 +38,17 @@ Accepted design decisions relevant to current implementation work: - The build backend is `uv_build` (`>=0.11.32,<0.12.0`), declared in the `[build-system]` table of `pyproject.toml`. +The transport-free validators in `episodic.canonical.validation` belong to the +canonical domain layer. They provide dependency-free, side-effect-free +argument-shape checks that adapters may call at their boundaries; domain- +specific validation remains with the domain type it guards. In particular, +`validate_async_callable(callback, attribute_name)` requires a callable whose +invocation returns an awaitable and raises `TypeError` otherwise. The health +port uses it in `ProbeHealthObserver.from_checks`, while the API dependency +boundary uses it for readiness probes, authorization decisions, and shutdown +hooks. Keep this module free of adapter imports so the dependency direction +remains inward. + The `Makefile` prepends `$(HOME)/.local/bin` and `$(HOME)/.bun/bin` to `PATH` so that tools installed via `uv` and Bun are discoverable by all Make targets without requiring manual shell `PATH` configuration. @@ -64,8 +75,9 @@ The target runs this repository-wide pipeline, in order: 3. the focused built-in Pylint 4 rules under managed PyPy; 4. the `df12-python-lints` Pylint plug-in under CPython 3.14, including its separate future-annotations pass; -5. `ambrleaks` over Syrupy snapshots under `tests`; and -6. a blocking Skylos dead-code scan. +5. `ambrleaks` over Syrupy snapshots under `tests`; +6. a blocking Skylos dead-code scan; and +7. a blocking PyChase code-duplication gate. The built-in Pylint pass is invoked through `uv tool run --python pypy` with the pinned `pylint-pypy-shim` wrapper from @@ -136,10 +148,12 @@ both commands. Maintainers must update both package pins together and validate the complete `make lint` pipeline. Skylos is separately provisioned by the Makefile at exact release `4.33.2` and -runs locally with concise, non-interactive output. The lint command disables -uploads and provenance collection, selects only dead-code analysis, and fails -when an unexplained finding remains. It does not invoke cloud or Large Language -Model (LLM) analysis and never modifies source files. +runs locally with concise, non-interactive output under CPython 3.14. Keep this +interpreter requirement: Skylos's runtime `ast` parser must understand the +project's Python 3.14 syntax. The lint command disables uploads and provenance +collection, selects only dead-code analysis, and fails when an unexplained +finding remains. It does not invoke cloud or Large Language Model (LLM) +analysis and never modifies source files. Treat every new finding as dead code until its runtime caller is verified. Remove genuine dead code. For framework callbacks, protocol implementations, or @@ -161,18 +175,60 @@ rule cannot describe the boundary. Its reason must include who or what calls the symbol and how that was verified. Add one with: ```shell -make skylos-allow NAME=registered_handler \ +make skylos-allow SYMBOL=registered_handler \ REASON="Loaded by the plugin registry; verified in the registry contract test" ``` -The target refuses empty `NAME` and `REASON` values and stores the explanation -under `[tool.skylos.whitelist.documented]`; it does not create entry-point -rules. Do not generate baselines, scrape reports into configuration, or add -bulk unexplained exceptions. Use inline suppression only when neither an -entry-point rule nor a named exception can describe the boundary, and keep its -reason beside the suppression. Temporary exceptions must name an owner, -tracking reference, and expiry condition. Remove exception entries when the -dynamic boundary disappears. +The target refuses empty `SYMBOL` and `REASON` values and stores the +explanation under `[tool.skylos.whitelist.documented]`; it does not create +entry-point rules. Do not generate baselines, scrape reports into +configuration, or add bulk unexplained exceptions. Use inline suppression only +when neither an entry-point rule nor a named exception can describe the +boundary, and keep its reason beside the suppression. Temporary exceptions must +name an owner, tracking reference, and expiry condition. Remove exception +entries when the dynamic boundary disappears. + +## Code-duplication gate + +`make lint` (and the standalone `make duplication` target) runs +`scripts/duplication_gate.py check`, which drives the pinned PyChase 0.1.0 +detector with the `[tool.pychase]` settings in `pyproject.toml` and fails while +unsuppressed duplicate pairs remain. Findings name both members as +`path:lines ~ path:lines` spans with `path::qualname` unit keys, so a finding +can be pasted directly into a refactoring task or coding-agent prompt. The tool +choice, thresholds, and module exclusions follow ADR-018 and +[the duplication head-to-head](pychase-pyscn-duplication-head-to-head.md). + +Treat every new finding as copy-paste until proven otherwise: prefer extracting +the shared logic over suppressing the report. When the parallel structure is +intentional — wire-format declarations, Template Method subclasses over a +shared base, or independently versioned schemas — record a reasoned exception: + +```shell +make duplication-allow FIRST='episodic/api/serializers.py::serialize_series_profile' \ + SECOND='episodic/api/serializers.py::serialize_episode_template' \ + REASON="Wire-format serializers are literal response-schema declarations for distinct resources" +``` + +Omit `SECOND` to silence every pair one unit participates in. The target +refuses empty keys or reasons and stores entries under +`[tool.duplication_gate]`. The gate reports entries whose duplication has been +resolved as stale; remove them in the same change. Declarative module patterns +(storage record models, record/domain mappers, repository protocols, typed +request modules) are excluded in `[tool.pychase]` with documented reasons +rather than per-pair entries. + +Allowlist updates take an advisory cross-process lock, so a concurrent writer +waits until the current update completes. If a writer is cancelled or +interrupted, closing its lock descriptor releases the lock. Each successful +update fsyncs a temporary sibling before atomically replacing `pyproject.toml` +with the destination file's mode preserved. This prevents concurrent +allowlist updates from overwriting one another. + +The gate runs its own Python 3.13 environment because PyChase 0.1.0 imports +`ast` aliases removed in Python 3.14, and it re-executes itself with +`PYTHONHASHSEED=0` so LSH bucketing stays deterministic. Its helper tests run +through `make duplication-test`. ## Spelling policy @@ -1505,6 +1561,10 @@ The port surface is intentionally split: - `GenerationRunRepository` creates, fetches, lists, and updates run state. - `GenerationEventLog` appends events and allocates per-run `EventSeq` values inside the adapter. +- `event_page_minimum_sequence` owns the shared cursor, limit, and offset + invariant for every `GenerationEventLog` adapter. Keep transport parsing in + the inbound adapter and invoke this helper only after values become domain + types. - `GenerationRunEventStore` composes the run repository and event log for durable generation-run storage. `CanonicalUnitOfWork.generation_runs` exposes this port; `SqlAlchemyUnitOfWork` binds a `SqlAlchemyGenerationRunStore` to @@ -1540,6 +1600,11 @@ documents. It commits that request-scoped unit of work before calling serves the persisted episode TEI as JSON by default or raw `application/tei+xml` with content negotiation. +`episodic.canonical.episode_factory.build_draft_episode` owns the common +initial field set for new draft episodes. Ingestion services call it after they +have chosen the owning profile and parsed the TEI header; do not use it when +rehydrating persisted episodes or applying lifecycle transitions. + `DraftScriptGenerator` is the generation seam. The launcher projects canonical source documents and resolved host or guest reference-document revisions into `DraftScriptRequest`; `LLMDraftScriptGenerator` is the current single-pass diff --git a/docs/pychase-pyscn-duplication-head-to-head.md b/docs/pychase-pyscn-duplication-head-to-head.md new file mode 100644 index 00000000..040824c1 --- /dev/null +++ b/docs/pychase-pyscn-duplication-head-to-head.md @@ -0,0 +1,150 @@ +# PyChase and pyscn code-duplication head-to-head + +## Outcome + +PyChase 0.1.0 is the choice for Episodic's blocking duplication gate. On the +labelled corpus it reported every syntactic clone with no false positives and +no unlabelled noise, and its unit-level findings carry qualified names that +feed directly into remediation. pyscn 1.29.1 matched PyChase's syntactic recall +and was the only tool to detect the Type-4 semantic clone, but it could not do +so without also reporting a false positive, and its similarity scores compress +into a narrow band that offers no threshold separating the two. + +Neither tool detects semantically equivalent code reliably. The adopted gate +therefore targets copy-paste duplication (Types 1-3) and treats semantic +duplication as a review concern, not a machine-checked one. + +## What each project means by duplication + +The [pyscn repository](https://github.com/ludo-technologies/pyscn) implements +Type 1-4 clone detection using APTED tree edit distance over parsed functions, +with per-type thresholds, optional data-flow analysis for Type-4 findings, and +locality-sensitive-hashing (LSH) acceleration. + +The [PyChase repository](https://github.com/Mayne-X/PyChase) implements Type +1-3 detection by normalizing the abstract syntax tree (replacing identifiers +and literals with placeholders), shingling the normalized node sequence, and +scoring candidate pairs by Jaccard similarity, with MinHash and LSH above 200 +units. + +Those contracts overlap but are not the same, so the comparison scores separate +`syntactic-clone` (Types 1-3) and `semantic-clone` (Type 4) lanes. A single +blended score would punish PyChase for a capability it does not claim. + +## Method + +The comparison was run on 2026-08-22 using the released packages pyscn 1.29.1 +and PyChase 0.1.0, both through `uvx` without becoming project dependencies. + +The checked-in corpus labels ten unit pairs fixed before either scan: + +- five clone pairs: one Type-1 copy, one Type-2 rename, one Type-3 + modification, one Type-4 semantic rewrite, and one Type-2 method clone across + two classes. +- five non-clone controls: parser, builder, and scanner pairs that share only + idiomatic structure, plus two numeric folds whose merging would conflate + distinct semantics. + +Both tools ran at permissive capability settings (all clone types enabled, +minimum sizes below every corpus unit) so the corpus measures detection ability +rather than configuration taste. Findings were matched to labels by unordered +span overlap. Unlabelled findings were preserved separately instead of being +converted into false positives after the fact. The raw reports, oracle, +normalizer, and normalized counts are under `benchmarks/duplication/`. + +## Labelled corpus results + +| Tool | Lane | TP | FP | FN | TN | Unmatched | Precision | Recall | F1 | +| ------------- | --------------- | --: | --: | --: | --: | --------: | ----------: | -----: | ----------: | +| PyChase 0.1.0 | Syntactic clone | 4 | 0 | 0 | 3 | 0 | 100.0% | 100.0% | 100.0% | +| pyscn 1.29.1 | Syntactic clone | 4 | 1 | 0 | 2 | 0 | 80.0% | 100.0% | 88.9% | +| PyChase 0.1.0 | Semantic clone | 0 | 0 | 1 | 2 | 0 | Not defined | 0.0% | Not defined | +| pyscn 1.29.1 | Semantic clone | 1 | 0 | 0 | 2 | 14 | 100.0% | 100.0% | 100.0% | + +*Table 1: Confusion matrices for the pre-labelled corpus at permissive +settings.* + +The pyscn semantic-lane row needs qualification. pyscn found the true Type-4 +pair at similarity 0.77, but it scored the `longest_valid_streak` and +`count_state_changes` control pair — two different algorithms — at 0.93, and +emitted fourteen unlabelled cross-pairs between unrelated corpus functions at +0.73-0.81. No threshold accepts the true semantic clone while rejecting the +false one because the false positive scores higher. PyChase assigned the +renamed Type-2 clones similarity 1.0 and reported no control pair at any +threshold down to 0.6. + +pyscn's similarity band is also compressed: near-identical code rarely scores +above 0.85 unless it is byte-identical, so the usable strict range collapses to +a single step between 0.8 and exact match. + +## Configuration tuning + +Configurations were tuned generationally against the corpus oracle, with the +production scan as the second-generation fitness signal; the score tables are +retained under `benchmarks/duplication/results/`. + +- Generation 1 swept 60 pyscn and 189 PyChase configurations against the + corpus. Both plateaued at F1 0.889 over all ten labels (perfect precision, + Type-4 unreachable without loss). PyChase held that plateau across the whole + swept threshold range at or below 0.75 and every size floor, while pyscn's + best region required disabling Type-4 entirely. +- Generation 2 ran the plateau configurations over `episodic/`: + 63-842 raw findings depending on size floors, dominated by declarative code. +- Generation 3 swept stricter gate candidates and selected threshold 0.9, + minimum 13 source lines, and minimum 50 normalized AST nodes, which kept + every corpus syntactic clone of gate-relevant size while reducing the + production scan to 84 candidates. + +## Episodic production scan and adjudication + +Every candidate from the selected configuration was adjudicated by reading both +members ([the production adjudication report](results/production-adjudication.json)): + +| Disposition | Count | Interpretation | +| -------------------------- | ----: | --------------------------------------------------------------------------- | +| Declarative-module pairs | 53 | Records, mappers, protocols, and typed request modules; excluded by pattern | +| Genuine copy-paste (fixed) | 4 | The duplicated `_validate_async_callable` helper and the `log_*` triple | +| Allowlisted with reasons | 27 | Parallel structure adjudicated as intentional; recorded in `pyproject.toml` | + +*Table 2: Adjudication of the 84 production candidates at gate settings.* + +The declarative cluster is the dominant false-positive mode for +normalization-based detection: once identifiers and literals become +placeholders, any two SQLAlchemy record classes, msgspec request types, +repository protocols, or field-by-field mappers look identical. The gate +excludes those module patterns with documented reasons rather than allowlisting +dozens of pairs individually. + +## Operational caveats + +- PyChase 0.1.0 imports `ast.Str` and related aliases that Python 3.14 + removed, so the gate pins Python 3.13 for its own environment. +- Above 200 scanned units PyChase buckets MinHash signatures with the + built-in `hash()`. Unless `PYTHONHASHSEED` is pinned, near-threshold findings + appear and disappear between runs; the gate re-executes itself with a fixed + seed. Even pinned, LSH candidate recall near the threshold is probabilistic + by construction (roughly 96% at similarity 0.9 with the default banding), + while exact structural matches are always caught. +- PyChase's advertised `# pychase: ignore` pragma is actually spelled + `# dry4python: ignore` in 0.1.0 and applies only to functions, not classes. + The gate therefore implements suppression itself through reviewable allow + entries instead of relying on pragmas. +- `pyscn check` treats clones as warnings only, and its clone findings carry + spans without unit names, so a blocking gate would need report + post-processing under either tool. + +## Recommendation + +- Use PyChase behind `scripts/duplication_gate.py` as the blocking + copy-paste gate, with the declarative-module exclusions and the reasoned + allowlist in `pyproject.toml` (ADR-018). +- Do not rely on either tool for semantic (Type-4) duplication. pyscn's + semantic detector inverted the ranking between a true and a false semantic + pair on this corpus. +- Revisit the comparison when PyChase gains Python 3.14 support or pyscn + widens its similarity band; both caveats are release-specific behaviour, not + architectural limits. + +The corpus is intentionally small and Episodic is only one codebase. The +results establish behaviour for these released versions and fixtures; they do +not validate project-authored claims or predict precision elsewhere. diff --git a/episodic/api/dependencies.py b/episodic/api/dependencies.py index 10afeb9a..f464b0ef 100644 --- a/episodic/api/dependencies.py +++ b/episodic/api/dependencies.py @@ -7,9 +7,9 @@ import collections.abc as cabc import dataclasses as dc -import inspect import typing as typ +from episodic.canonical.validation import validate_async_callable from episodic.observability import NoopMetrics, NoopTracer, PerfCounterClock from .authorization import AuthorizationPort, PermitAll @@ -27,21 +27,6 @@ type ShutdownHook = cabc.Callable[[], cabc.Coroutine[None, None, None]] -def _validate_async_callable(callback: object, attribute_name: str) -> None: - """Require a coroutine function for adapter hooks invoked with ``await``.""" - if not callable(callback): - msg = f"{attribute_name} must be callable." - raise TypeError(msg) - - if inspect.iscoroutinefunction(callback) or inspect.iscoroutinefunction( - type(callback).__call__ - ): - return - - msg = f"{attribute_name} must be an async callable returning an awaitable." - raise TypeError(msg) - - def _validate_readiness_probe( probe: object, *, @@ -57,7 +42,7 @@ def _validate_readiness_probe( if not hasattr(probe, "check"): msg = f"{label} must define an async check." raise TypeError(msg) - _validate_async_callable(probe.check, label) # type: ignore[union-attr] # Runtime shape checks narrow externally supplied values beyond static inference. + validate_async_callable(probe.check, label) # type: ignore[union-attr] # Runtime shape checks narrow externally supplied values beyond static inference. def _validate_authorization_port(port: object) -> None: @@ -65,7 +50,7 @@ def _validate_authorization_port(port: object) -> None: if not isinstance(port, AuthorizationPort): msg = "ApiDependencies.authorization must implement AuthorizationPort." raise TypeError(msg) - _validate_async_callable(port.decide, "ApiDependencies.authorization.decide") + validate_async_callable(port.decide, "ApiDependencies.authorization.decide") @dc.dataclass(frozen=True, slots=True) @@ -80,7 +65,7 @@ def __post_init__(self) -> None: if not self.name.strip(): msg = "ReadinessProbe.name must be a non-empty string." raise ValueError(msg) - _validate_async_callable(self.check, "ReadinessProbe.check") + validate_async_callable(self.check, "ReadinessProbe.check") @dc.dataclass(frozen=True, slots=True) @@ -125,7 +110,7 @@ def __post_init__(self) -> None: msg = "ApiDependencies.health_observer must define observe()." raise TypeError(msg) for shutdown_hook in self.shutdown_hooks: - _validate_async_callable( + validate_async_callable( shutdown_hook, "ApiDependencies.shutdown_hooks entries", ) diff --git a/episodic/canonical/adapters/generation_runs.py b/episodic/canonical/adapters/generation_runs.py index 77255bd2..3fb1670f 100644 --- a/episodic/canonical/adapters/generation_runs.py +++ b/episodic/canonical/adapters/generation_runs.py @@ -13,6 +13,7 @@ GenerationRun, GenerationRunStatus, JsonMapping, + _validate_terminal_run_lifecycle, ) from episodic.canonical.generation_run_errors import ( RunAlreadyTerminal, @@ -21,6 +22,7 @@ from episodic.canonical.generation_run_ports import ( EventSeq, GenerationRunStatusUpdate, + event_page_minimum_sequence, event_seq, ) from episodic.orchestration._types import _log_event @@ -40,22 +42,6 @@ def _default_time_provider() -> TimeProvider: return _now_utc -def _event_page_minimum_seq( - *, - after_seq: EventSeq | None, - limit: int, - offset: int, -) -> int: - """Validate event-page arguments and return the cursor boundary.""" - if limit < 0 or offset < 0: - msg = "limit and offset must be non-negative." - raise ValueError(msg) - if after_seq is not None and offset != 0: - msg = "after_seq and offset cannot be combined." - raise ValueError(msg) - return int(after_seq) if after_seq is not None else 0 - - @dc.dataclass(slots=True) class InMemoryGenerationRunStore(InMemoryGenerationCheckpointMixin): """In-memory reference adapter for the composite generation-run port.""" @@ -230,6 +216,11 @@ async def update_run_status( requested_status=update.status.value, ), ) + _validate_terminal_run_lifecycle( + status=update.status, + current_node=update.current_node, + ended_at=update.ended_at, + ) updated = dc.replace( run, status=update.status, @@ -370,7 +361,7 @@ async def list_events( offset: int = 0, ) -> tuple[GenerationEvent, ...]: """List events for a run after an optional sequence cursor.""" - minimum_seq = _event_page_minimum_seq( + minimum_seq = event_page_minimum_sequence( after_seq=after_seq, limit=limit, offset=offset, diff --git a/episodic/canonical/domain.py b/episodic/canonical/domain.py index 9ac6e2c6..5ad0ae2e 100644 --- a/episodic/canonical/domain.py +++ b/episodic/canonical/domain.py @@ -123,6 +123,23 @@ def is_terminal(self) -> bool: return self is not CheckpointStatus.CREATED +def _validate_terminal_run_lifecycle( + *, + status: GenerationRunStatus, + current_node: str | None, + ended_at: dt.datetime | None, +) -> None: + """Validate lifecycle fields required by terminal generation runs.""" + if not status.is_terminal(): + return + if current_node is not None: + msg = "terminal generation runs must not have a current node" + raise ValueError(msg) + if ended_at is None: + msg = "terminal generation runs must have an end time" + raise ValueError(msg) + + class CheckpointAction(enum.StrEnum): """Reviewer actions accepted for a generation checkpoint.""" @@ -155,6 +172,11 @@ class GenerationRun: def __post_init__(self) -> None: """Validate generation-run invariants.""" + _validate_terminal_run_lifecycle( + status=self.status, + current_node=self.current_node, + ended_at=self.ended_at, + ) _validate_non_empty_text(self.actor, "actor") _validate_optional_text(self.current_node, "current_node") _validate_optional_text(self.error_message, "error_message") diff --git a/episodic/canonical/episode_factory.py b/episodic/canonical/episode_factory.py new file mode 100644 index 00000000..032a9220 --- /dev/null +++ b/episodic/canonical/episode_factory.py @@ -0,0 +1,53 @@ +"""Canonical construction helpers for new draft episodes. + +This application-layer module owns the common initial state for new canonical +episodes. It accepts only domain values and has no persistence or transport +dependencies, allowing both ingestion workflows to share it without coupling +their orchestration steps. +""" + +import typing as typ + +from .domain import ApprovalState, CanonicalEpisode, EpisodeStatus, TeiHeader + +if typ.TYPE_CHECKING: + import datetime as dt + import uuid + + +def build_draft_episode( + *, + episode_id: uuid.UUID, + series_profile_id: uuid.UUID, + header: TeiHeader, + now: dt.datetime, +) -> CanonicalEpisode: + """Build a new canonical episode in its initial draft state. + + Parameters + ---------- + episode_id : uuid.UUID + Identifier reserved for the new episode. + series_profile_id : uuid.UUID + Identifier of the profile that owns the episode. + header : TeiHeader + Parsed TEI header supplying the title and source XML. + now : datetime.datetime + Timestamp applied to both creation and update fields. + + Returns + ------- + CanonicalEpisode + A draft episode with draft approval state and header-derived content. + """ + return CanonicalEpisode( + id=episode_id, + series_profile_id=series_profile_id, + tei_header_id=header.id, + title=header.title, + tei_xml=header.raw_xml, + status=EpisodeStatus.DRAFT, + approval_state=ApprovalState.DRAFT, + created_at=now, + updated_at=now, + ) diff --git a/episodic/canonical/generation_persistence.py b/episodic/canonical/generation_persistence.py index a4ac3b01..3111385d 100644 --- a/episodic/canonical/generation_persistence.py +++ b/episodic/canonical/generation_persistence.py @@ -16,15 +16,14 @@ import tei_rapporteur as tei from episodic.canonical.domain import ( - ApprovalState, CanonicalEpisode, - EpisodeStatus, EpisodeTeiUpdate, IngestionJob, IntakeState, TeiHeader, ) from episodic.canonical.entity_protocols import SourceDocumentProjectionResult +from episodic.canonical.episode_factory import build_draft_episode from episodic.canonical.generation_persistence_projection import ( source_document_from_attachment, ) @@ -132,9 +131,9 @@ async def _materialise_or_reuse_episode( title=request.title, now=now, ) - episode = _build_placeholder_episode( + episode = build_draft_episode( episode_id=episode_id, - job=job, + series_profile_id=job.series_profile_id, header=header, now=now, ) @@ -330,27 +329,6 @@ def _placeholder_tei_xml(title: str) -> str: return tei.emit_xml(document) -def _build_placeholder_episode( - *, - episode_id: uuid.UUID, - job: IngestionJob, - header: TeiHeader, - now: dt.datetime, -) -> CanonicalEpisode: - """Build a placeholder canonical episode.""" - return CanonicalEpisode( - id=episode_id, - series_profile_id=job.series_profile_id, - tei_header_id=header.id, - title=header.title, - tei_xml=header.raw_xml, - status=EpisodeStatus.DRAFT, - approval_state=ApprovalState.DRAFT, - created_at=now, - updated_at=now, - ) - - async def _upload_for_source( uow: CanonicalUnitOfWork, source: IngestionJobSource, diff --git a/episodic/canonical/generation_run_ports.py b/episodic/canonical/generation_run_ports.py index 262110f9..f41f1996 100644 --- a/episodic/canonical/generation_run_ports.py +++ b/episodic/canonical/generation_run_ports.py @@ -65,6 +65,43 @@ def event_seq(value: int) -> EventSeq: return EventSeq(value) +def event_page_minimum_sequence( + *, + after_seq: EventSeq | None, + limit: int, + offset: int, +) -> int: + """Validate event-page arguments and return the exclusive sequence bound. + + Parameters + ---------- + after_seq : EventSeq | None + Optional exclusive cursor for the event sequence. + limit : int + Maximum number of events requested. + offset : int + Number of events to skip after cursor selection. + + Returns + ------- + int + The exclusive event-sequence bound, or zero without a cursor. + + Raises + ------ + ValueError + If pagination bounds are negative or a cursor is combined with an + offset. + """ + if limit < 0 or offset < 0: + msg = "limit and offset must be non-negative." + raise ValueError(msg) + if after_seq is not None and offset != 0: + msg = "after_seq and offset cannot be combined." + raise ValueError(msg) + return int(after_seq) if after_seq is not None else 0 + + @typ.runtime_checkable class GenerationRunRepository(typ.Protocol): """Repository port for generation-run aggregate roots.""" diff --git a/episodic/canonical/health.py b/episodic/canonical/health.py index fc3915f6..7d3d23ae 100644 --- a/episodic/canonical/health.py +++ b/episodic/canonical/health.py @@ -20,9 +20,10 @@ import collections.abc as cabc import dataclasses as dc import enum -import inspect import typing as typ +from episodic.canonical.validation import validate_async_callable + type HealthCheckCallback = cabc.Callable[[], cabc.Coroutine[None, None, bool]] @@ -73,21 +74,6 @@ async def observe(self) -> HealthReport: """Return the current health report.""" -def _validate_async_callable(callback: object, attribute_name: str) -> None: - """Require a coroutine function for health checks invoked with ``await``.""" - if not callable(callback): - msg = f"{attribute_name} must be callable." - raise TypeError(msg) - - if inspect.iscoroutinefunction(callback) or inspect.iscoroutinefunction( - type(callback).__call__ - ): - return - - msg = f"{attribute_name} must be an async callable returning an awaitable." - raise TypeError(msg) - - @dc.dataclass(frozen=True, slots=True) class ProbeHealthObserver: """Observe health by evaluating named asynchronous checks.""" @@ -111,7 +97,7 @@ def from_checks( if not name.strip(): msg = "Health check names must be non-empty strings." raise ValueError(msg) - _validate_async_callable(callback, f"Health check {name!r}") + validate_async_callable(callback, f"Health check {name!r}") return cls(_checks=check_tuple) async def observe(self) -> HealthReport: diff --git a/episodic/canonical/services.py b/episodic/canonical/services.py index 74a86892..b0063285 100644 --- a/episodic/canonical/services.py +++ b/episodic/canonical/services.py @@ -27,7 +27,6 @@ ApprovalEvent, ApprovalState, CanonicalEpisode, - EpisodeStatus, IngestionJob, IngestionRequest, IngestionStatus, @@ -35,6 +34,7 @@ SourceDocument, TeiHeader, ) +from .episode_factory import build_draft_episode from .provenance import build_tei_header_provenance, merge_tei_header_provenance from .tei import TeiHeaderPayload, parse_tei_header @@ -88,26 +88,6 @@ def _create_tei_header( ) -def _create_canonical_episode( - episode_id: uuid.UUID, - series_profile: SeriesProfile, - header: TeiHeader, - now: dt.datetime, -) -> CanonicalEpisode: - """Create a canonical episode entity.""" - return CanonicalEpisode( - id=episode_id, - series_profile_id=series_profile.id, - tei_header_id=header.id, - title=header.title, - tei_xml=header.raw_xml, - status=EpisodeStatus.DRAFT, - approval_state=ApprovalState.DRAFT, - created_at=now, - updated_at=now, - ) - - def _create_ingestion_job( job_id: uuid.UUID, series_profile_id: uuid.UUID, @@ -252,7 +232,12 @@ async def ingest_sources( job_id = _new_storage_id() header = _create_tei_header(header_id, header_payload, request.tei_xml, now) - episode = _create_canonical_episode(episode_id, series_profile, header, now) + episode = build_draft_episode( + episode_id=episode_id, + series_profile_id=series_profile.id, + header=header, + now=now, + ) job = _create_ingestion_job(job_id, series_profile.id, episode_id, now) await uow.tei_headers.add(header) diff --git a/episodic/canonical/storage/generation_runs.py b/episodic/canonical/storage/generation_runs.py index 98c4d99c..7e006386 100644 --- a/episodic/canonical/storage/generation_runs.py +++ b/episodic/canonical/storage/generation_runs.py @@ -10,8 +10,10 @@ GenerationRun, GenerationRunStatus, JsonMapping, + _validate_terminal_run_lifecycle, ) from episodic.canonical.generation_run_errors import RunAlreadyTerminal, RunNotFound +from episodic.canonical.generation_run_ports import event_page_minimum_sequence from episodic.orchestration._types import _log_event from .generation_run_mappers import ( @@ -39,22 +41,6 @@ ) -def _minimum_event_sequence( - after_seq: EventSeq | None, - *, - limit: int, - offset: int, -) -> int: - """Validate event-page arguments and return the exclusive sequence bound.""" - if limit < 0 or offset < 0: - msg = "limit and offset must be non-negative." - raise ValueError(msg) - if after_seq is not None and offset != 0: - msg = "after_seq and offset cannot be combined." - raise ValueError(msg) - return int(after_seq) if after_seq is not None else 0 - - class SqlAlchemyGenerationRunStore: """Durable generation-run repository and event-log adapter.""" @@ -196,6 +182,11 @@ async def update_run_status( ) -> GenerationRun: """Update lifecycle fields for a run.""" record = await self._require_mutable_run(run_id, lock=True) + _validate_terminal_run_lifecycle( + status=update.status, + current_node=update.current_node, + ended_at=update.ended_at, + ) record.status = update.status record.current_node = update.current_node record.ended_at = update.ended_at @@ -238,7 +229,7 @@ async def claim_run_for_execution( updated_at=now, ) ) - cursor_result = typ.cast("CursorResult[typ.Any]", result) + cursor_result = typ.cast("CursorResult[object]", result) if cursor_result.rowcount == 1: record = await self._get_record(run_id) if record is None: # pragma: no cover - guarded by updated row. @@ -325,8 +316,8 @@ async def list_events( offset: int = 0, ) -> tuple[GenerationEvent, ...]: """List events for a run after an optional sequence cursor.""" - minimum_seq = _minimum_event_sequence( - after_seq, + minimum_seq = event_page_minimum_sequence( + after_seq=after_seq, limit=limit, offset=offset, ) diff --git a/episodic/canonical/validation.py b/episodic/canonical/validation.py new file mode 100644 index 00000000..1907675a --- /dev/null +++ b/episodic/canonical/validation.py @@ -0,0 +1,47 @@ +"""Transport-free runtime validation helpers shared across layers. + +This module owns small argument-shape validators that both the domain layer +and inbound adapters need at object-construction time. It belongs to the +hexagonal architecture domain layer, so adapters may import it while it +imports nothing beyond the standard library. + +Scope and re-use policy: helpers here must be dependency-free, side-effect +free, and applicable to any layer. Domain-specific validation stays with the +domain type it guards. +""" + +import inspect + + +def validate_async_callable(callback: object, attribute_name: str) -> None: + """Require a coroutine function for callbacks invoked with ``await``. + + Parameters + ---------- + callback : object + Candidate callback supplied by configuration or dependency wiring. + attribute_name : str + Human-readable attribute label used in error messages. + + Raises + ------ + TypeError + If the callback is not callable or would not return an awaitable. + + Examples + -------- + >>> async def probe() -> bool: + ... return True + >>> validate_async_callable(probe, "ReadinessProbe.check") + """ + if not callable(callback): + msg = f"{attribute_name} must be callable." + raise TypeError(msg) + + if inspect.iscoroutinefunction(callback) or inspect.iscoroutinefunction( + type(callback).__call__ + ): + return + + msg = f"{attribute_name} must be an async callable returning an awaitable." + raise TypeError(msg) diff --git a/episodic/generation/launcher.py b/episodic/generation/launcher.py index 9a93a0dd..87b6e3ab 100644 --- a/episodic/generation/launcher.py +++ b/episodic/generation/launcher.py @@ -439,7 +439,7 @@ async def _record_success_events_and_costs( claimed.run.id, update=GenerationRunStatusUpdate( status=GenerationRunStatus.SUCCEEDED, - current_node="complete", + current_node=None, ended_at=self.clock(), ), ) @@ -491,7 +491,7 @@ async def _record_failure(self, run_id: uuid.UUID, failure: Failure) -> None: run_id, update=GenerationRunStatusUpdate( status=GenerationRunStatus.FAILED, - current_node="failed", + current_node=None, ended_at=self.clock(), error_message=failure.message, error_category=failure.category, diff --git a/episodic/logging.py b/episodic/logging.py index eb2055c7..38d5c45e 100644 --- a/episodic/logging.py +++ b/episodic/logging.py @@ -145,6 +145,28 @@ def _format_message(template: str, args: tuple[object, ...]) -> str: return template % args if args else template +def _log_at( + logger: _CompatibleLogger, + level: int, + message: str, + *, + exc_info: object | None, +) -> None: + """Dispatch to a convenience method with a stdlib ``log()`` fallback.""" + method_name = logging.getLevelName(level).lower() + try: + method = getattr(typ.cast("_SupportsConvenienceLog", logger), method_name) + except AttributeError: + typ.cast("_SupportsLogMethod", logger).log( + level, + message, + exc_info=exc_info, + stack_info=False, + ) + else: + method(message, exc_info=exc_info, stack_info=False) + + def log_info( logger: _CompatibleLogger, template: str, @@ -165,20 +187,7 @@ def log_info( exc_info : object | None, optional Exception info to attach to the log record. """ - message = _format_message(template, args) - try: - typ.cast("_SupportsConvenienceLog", logger).info( - message, - exc_info=exc_info, - stack_info=False, - ) - except (AttributeError, TypeError): # fmt: skip - typ.cast("_SupportsLogMethod", logger).log( - logging.INFO, - message, - exc_info=exc_info, - stack_info=False, - ) + _log_at(logger, logging.INFO, _format_message(template, args), exc_info=exc_info) def log_warning( @@ -201,20 +210,7 @@ def log_warning( exc_info : object | None, optional Exception info to attach to the log record. """ - message = _format_message(template, args) - try: - typ.cast("_SupportsConvenienceLog", logger).warning( - message, - exc_info=exc_info, - stack_info=False, - ) - except (AttributeError, TypeError): # fmt: skip - typ.cast("_SupportsLogMethod", logger).log( - logging.WARNING, - message, - exc_info=exc_info, - stack_info=False, - ) + _log_at(logger, logging.WARNING, _format_message(template, args), exc_info=exc_info) def log_error( @@ -237,20 +233,7 @@ def log_error( exc_info : object | None, optional Exception info to attach to the log record. """ - message = _format_message(template, args) - try: - typ.cast("_SupportsConvenienceLog", logger).error( - message, - exc_info=exc_info, - stack_info=False, - ) - except (AttributeError, TypeError): # fmt: skip - typ.cast("_SupportsLogMethod", logger).log( - logging.ERROR, - message, - exc_info=exc_info, - stack_info=False, - ) + _log_at(logger, logging.ERROR, _format_message(template, args), exc_info=exc_info) __all__ = ( diff --git a/pyproject.toml b/pyproject.toml index 503504de..7cb8a5b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -782,6 +782,118 @@ names = [] "StaleEventSequence" = "Reserved generation-run port error required by the accepted lifecycle contract." "UploadInitRequest" = "Reserved source-intake request DTO required by the accepted upload contract." +# PyChase settings for the blocking duplication gate (scripts/duplication_gate.py). +# The thresholds follow the tuned benchmark configuration recorded in +# docs/pychase-pyscn-duplication-head-to-head.md. The excluded modules are +# declarative by convention: identifier and literal normalization makes +# schema-shaped declarations (models, mappers, protocol interfaces, typed +# request/response modules) structurally identical without any copy-paste. +[tool.pychase] +paths = ["episodic", "openai_test_types.py"] +threshold = 0.9 +min-lines = 13 +min-nodes = 50 +exclude = [ + "*/storage/*models.py", # SQLAlchemy record declarations + "*_mappers.py", # field-by-field record/domain constructors + "*_protocols.py", # repository Protocol interfaces + "types.py", # typed request/response and enum declarations +] + +# Reasoned exceptions for the duplication gate. A `unit` entry silences every +# pair that unit participates in; a `pair` entry silences one unordered pair. +# Record entries with `make duplication-allow FIRST=... [SECOND=...] REASON=...` +# and remove them when the gate reports them as stale. +[tool.duplication_gate] + +[[tool.duplication_gate.allow]] +pair = ["episodic/api/app.py::_register_series_profile_routes", "episodic/api/app.py::_register_reference_document_routes"] +reason = "Route registration is a repetitive list of literal URL-to-resource bindings; merging would hide the route table behind indirection." + +[[tool.duplication_gate.allow]] +pair = ["episodic/api/helpers.py::build_profile_update_request", "episodic/api/helpers.py::build_template_update_request"] +reason = "Typed facades over the shared _build_typed_update_request helper; the residue binds each concrete request type." + +[[tool.duplication_gate.allow]] +pair = ["episodic/api/resources/episode_templates.py::EpisodeTemplateResource", "episodic/api/resources/series_profiles.py::SeriesProfileResource"] +reason = "Template Method subclasses whose bodies are per-domain configuration overrides; the shared logic lives in the resource base classes." + +[[tool.duplication_gate.allow]] +pair = ["episodic/api/resources/episode_templates.py::EpisodeTemplateHistoryResource", "episodic/api/resources/series_profiles.py::SeriesProfileHistoryResource"] +reason = "History resources only bind domain identifiers and serializers to the shared history base class." + +[[tool.duplication_gate.allow]] +pair = ["episodic/api/resources/reference_bindings.py::ReferenceBindingResource.on_get", "episodic/api/resources/reference_documents.py::ReferenceDocumentResource.on_get"] +reason = "Falcon single-entity GET handlers share the unit-of-work and error-mapping envelope while differing in path parameters, service call, and serializer." + +[[tool.duplication_gate.allow]] +pair = ["episodic/api/resources/reference_documents.py::ReferenceDocumentResource.on_patch", "episodic/api/resources/reference_documents.py::ReferenceDocumentsResource.on_post"] +reason = "Create and update handlers share a validate-then-serialize envelope but encode different HTTP semantics and optimistic-locking rules." + +[[tool.duplication_gate.allow]] +pair = ["episodic/api/serializers.py::serialize_series_profile", "episodic/api/serializers.py::serialize_episode_template"] +reason = "Wire-format serializers are literal response-schema declarations for distinct API resources." + +[[tool.duplication_gate.allow]] +unit = "episodic/api/serializers.py::serialize_ingestion_job_source" +reason = "Response-schema dict literal sharing only the UUID and timestamp conversion idiom with the other serializers." + +[[tool.duplication_gate.allow]] +unit = "episodic/canonical/domain.py::EpisodeTemplate" +reason = "Frozen dataclass domain entity whose declaration shape is inherently repetitive; the differing fields are the point." + +[[tool.duplication_gate.allow]] +pair = ["episodic/canonical/domain.py::EpisodeTemplateHistoryEntry", "episodic/canonical/domain.py::SeriesProfileHistoryEntry"] +reason = "History entries differ only in the parent foreign-key field that the storage layer keys on; a generic base would erase it." + +[[tool.duplication_gate.allow]] +unit = "episodic/canonical/idempotency.py::IdempotencyAcquireRequest" +reason = "Distinct bounded-context DTO coinciding with the upload DTOs only in the frozen-dataclass __post_init__ guard-call idiom." + +[[tool.duplication_gate.allow]] +pair = ["episodic/canonical/profile_templates/_brief_loaders.py::_load_revisions_by_id", "episodic/canonical/profile_templates/_brief_loaders.py::_load_documents_by_id"] +reason = "Two-call-site loader idiom whose shared logic already lives in _raise_if_missing_ids; the residue is type-specific attribute access." + +[[tool.duplication_gate.allow]] +pair = ["episodic/canonical/profile_templates/services/_typed.py::update_series_profile", "episodic/canonical/profile_templates/services/_typed.py::update_episode_template"] +reason = "Typed facades over the shared _update_versioned_entity helper; the duplicated text is per-entity configuration and docstrings." + +[[tool.duplication_gate.allow]] +pair = ["episodic/canonical/storage/history_repositories.py::SqlAlchemySeriesProfileHistoryRepository", "episodic/canonical/storage/history_repositories.py::SqlAlchemyEpisodeTemplateHistoryRepository"] +reason = "Protocol-conforming naming shims over the shared _HistoryRepositoryBase; only method names and generic parameters differ." + +[[tool.duplication_gate.allow]] +pair = ["episodic/generation/guest_bios.py::_parse_entry", "episodic/generation/show_notes.py::_parse_entry"] +reason = "Schema-specific strict-JSON parsers for distinct DTOs; the shared shape is the validate-then-construct idiom." + +[[tool.duplication_gate.allow]] +pair = ["episodic/generation/guest_bios.py::enrich_tei_with_guest_bios", "episodic/generation/show_notes.py::enrich_tei_with_show_notes"] +reason = "Accepted debt: guest bios re-implements tei_payload helpers; consolidating onto episodic/generation/tei_payload.py is tracked in PR #276." + +[[tool.duplication_gate.allow]] +pair = ["episodic/llm/openai_validation.py::_normalize_chat_provider_call_usage", "episodic/llm/openai_validation.py::_normalize_responses_provider_call_usage"] +reason = "Usage normalizers for two independently versioned OpenAI payload schemas; a shared field map would couple them." + +[[tool.duplication_gate.allow]] +pair = ["episodic/orchestration/_guest_bios_executor.py::_handle_generator_error", "episodic/orchestration/_show_notes_executor.py::_handle_generator_error"] +reason = "Per-tool error-translation dispatchers; each binds its own domain exception pair and tool label at the point of use." + +[[tool.duplication_gate.allow]] +pair = ["episodic/orchestration/_guest_bios_executor.py::GuestBiosToolExecutor.__post_init__", "episodic/orchestration/_show_notes_executor.py::ShowNotesToolExecutor.__post_init__"] +reason = "Frozen-dataclass lazy-default injection required by frozen=True; each executor builds a different generator configuration." + +[[tool.duplication_gate.allow]] +pair = ["episodic/worker/tasks.py::IoDiagnosticRequest", "episodic/worker/tasks.py::CpuDiagnosticRequest"] +reason = "Separate Celery wire contracts; the repeated shape is field declaration plus JSON codec boilerplate." + +[[tool.duplication_gate.allow]] +pair = ["episodic/worker/tasks.py::IoDiagnosticResult", "episodic/worker/tasks.py::CpuDiagnosticResult"] +reason = "Separate Celery wire contracts; the repeated shape is field declaration plus JSON codec boilerplate." + +[[tool.duplication_gate.allow]] +pair = ["episodic/asyncio_tasks.py::create_task", "episodic/asyncio_tasks.py::create_task_in_group"] +reason = "Thin public wrappers over one task-creation path differing only in the create_task callable; kept explicit for API ergonomics and typing." + [tool.hecate] root_packages = ["episodic"] default_rule_id = "ARCH001" @@ -829,6 +941,7 @@ allowed = ["domain_ports"] [[tool.hecate.groups]] name = "application" prefixes = [ + "episodic.canonical.episode_factory", "episodic.canonical.services", "episodic.canonical.ingestion_service", "episodic.canonical.idempotency_service", diff --git a/scripts/duplication_gate.py b/scripts/duplication_gate.py new file mode 100644 index 00000000..dea5f094 --- /dev/null +++ b/scripts/duplication_gate.py @@ -0,0 +1,575 @@ +#!/usr/bin/env -S uv run python +# /// script +# requires-python = ">=3.13,<3.14" +# dependencies = ["cyclopts", "pychase==0.1.0", "tomlkit"] +# /// +# Python 3.13 is required rather than the standard 3.14: pychase 0.1.0 +# imports the ast.Str/ast.Bytes/ast.Num aliases that Python 3.14 removed. +"""Blocking code-duplication gate over the pinned PyChase detector. + +The gate runs PyChase with the repository's ``[tool.pychase]`` settings, +removes pairs covered by reasoned ``[tool.duplication_gate]`` allow entries, +and fails while unsuppressed duplicate pairs remain. Stale allow entries are +reported so that resolved duplication does not leave dead configuration +behind. + +Allow entries name either one unit (``unit = "path::qualname"``), silencing +every pair that unit participates in, or one unordered pair +(``pair = ["path::qualname", "path::qualname"]``). Every entry records a +reason so exceptions stay reviewable in version control. +""" + +from __future__ import annotations + +import dataclasses as dc +import fcntl +import os +import sys +import tomllib +import typing as typ +from collections import abc as cabc +from contextlib import contextmanager +from pathlib import Path + +import cyclopts +import tomlkit + +# PyChase 0.1.0 exposes no public file collector, so keep this pinned private +# helper alongside the script's exact ``pychase==0.1.0`` dependency. +from pychase.cli import ( # ty: ignore[unresolved-import] # pychase installs only in this script's Python 3.13 environment. + Config, + _collect_files, +) +from pychase.engine import ( # ty: ignore[unresolved-import] # pychase installs only in this script's Python 3.13 environment. + find, +) +from typos_rollout_cache import atomic_write + +app = cyclopts.App(help="Run or configure the code-duplication gate.") + +REPO_ROOT = Path(__file__).resolve().parents[1] +PYPROJECT = REPO_ROOT / "pyproject.toml" +_PAIR_MEMBER_COUNT = 2 + + +class _DetectorMemberPayload(typ.TypedDict): + """Validated PyChase location and qualified-name payload.""" + + file: str + qualname: str + start_line: int + end_line: int + + +class _DetectorPairPayload(typ.TypedDict): + """Validated PyChase duplicate-pair payload.""" + + left: _DetectorMemberPayload + right: _DetectorMemberPayload + score: float + + +@dc.dataclass(frozen=True, slots=True) +class AllowEntry: + """One reasoned exception from the duplication gate. + + Attributes + ---------- + units : tuple[str, ...] + One unit key for a unit entry, or two for a pair entry. Unit keys + take the form ``"path::qualname"`` with a repository-relative path. + reason : str + Reviewable justification recorded alongside the entry. + """ + + units: tuple[str, ...] + reason: str + + def matches(self, first: str, second: str) -> bool: + """Report whether this entry silences the pair ``(first, second)``.""" + if len(self.units) == 1: + return self.units[0] in {first, second} + return {first, second} == set(self.units) + + +@dc.dataclass(frozen=True, slots=True) +class Finding: + """One reported duplicate pair in gate-neutral form. + + Attributes + ---------- + first : str + Unit key of the first member. + second : str + Unit key of the second member. + location_first : str + ``path:start-end`` source span of the first member. + location_second : str + ``path:start-end`` source span of the second member. + score : float + Detector-reported structural similarity. + """ + + first: str + second: str + location_first: str + location_second: str + score: float + + +type AllowlistReader = cabc.Callable[[Path], tuple[AllowEntry, ...]] +type FindingDetector = cabc.Callable[[], list[Finding]] + + +class GateConfigError(ValueError): + """Raised when the gate configuration is malformed.""" + + +class GateExecutionError(GateConfigError): + """Raised when required local configuration or detector access fails.""" + + +def _unit_key(member: _DetectorMemberPayload) -> str: + """Build the ``path::qualname`` key for one reported member.""" + return f"{Path(member['file']).as_posix()}::{member['qualname']}" + + +def _location(member: _DetectorMemberPayload) -> str: + """Build the ``path:start-end`` span for one reported member.""" + path = Path(member["file"]).as_posix() + return f"{path}:{member['start_line']}-{member['end_line']}" + + +def _detector_mapping(value: object, *, context: str) -> cabc.Mapping[str, object]: + """Validate one object supplied by the PyChase report.""" + if not isinstance(value, cabc.Mapping) or not all( + isinstance(key, str) for key in value + ): + msg = f"{context} must be an object with string keys" + raise TypeError(msg) + return typ.cast("cabc.Mapping[str, object]", value) + + +def _detector_required_string(value: object, *, context: str) -> str: + """Validate a required non-empty PyChase string field.""" + if not isinstance(value, str) or not value: + msg = f"{context} must be a non-empty string" + raise ValueError(msg) + return value + + +def _detector_start_line(value: object, *, context: str) -> int: + """Validate a positive PyChase member start line.""" + if not isinstance(value, int) or isinstance(value, bool): + msg = f"{context}.start_line must be a positive integer" + raise TypeError(msg) + if value < 1: + msg = f"{context}.start_line must be a positive integer" + raise ValueError(msg) + return value + + +def _detector_end_line( + value: object, + *, + start_line: int, + context: str, +) -> int: + """Validate a PyChase member end line against its start line.""" + if not isinstance(value, int) or isinstance(value, bool): + msg = f"{context}.end_line must not precede start_line" + raise TypeError(msg) + if value < start_line: + msg = f"{context}.end_line must not precede start_line" + raise ValueError(msg) + return value + + +def _detector_member(value: object, *, context: str) -> _DetectorMemberPayload: + """Validate one PyChase member before it enters gate logic.""" + member = _detector_mapping(value, context=context) + file = _detector_required_string(member.get("file"), context=f"{context}.file") + qualname = _detector_required_string( + member.get("qualname"), context=f"{context}.qualname" + ) + start_line = _detector_start_line(member.get("start_line"), context=context) + end_line = _detector_end_line( + member.get("end_line"), + start_line=start_line, + context=context, + ) + return { + "file": file, + "qualname": qualname, + "start_line": start_line, + "end_line": end_line, + } + + +def _detector_pairs(value: object) -> tuple[_DetectorPairPayload, ...]: + """Validate the PyChase pair collection at the detector boundary.""" + if not isinstance(value, cabc.Sequence) or isinstance(value, (str, bytes)): + msg = "PyChase report pairs must be an array" + raise TypeError(msg) + pairs: list[_DetectorPairPayload] = [] + for index, raw_pair in enumerate(value): + context = f"PyChase report pairs[{index}]" + pair = _detector_mapping(raw_pair, context=context) + score = pair.get("score") + if isinstance(score, bool) or not isinstance(score, (int, float)): + msg = f"{context}.score must be a number" + raise TypeError(msg) + pairs.append({ + "left": _detector_member(pair.get("left"), context=f"{context}.left"), + "right": _detector_member(pair.get("right"), context=f"{context}.right"), + "score": float(score), + }) + return tuple(pairs) + + +def normalize_findings(pairs: cabc.Iterable[_DetectorPairPayload]) -> list[Finding]: + """Convert raw PyChase pairs into gate findings. + + Parameters + ---------- + pairs : collections.abc.Iterable[_DetectorPairPayload] + Validated pair payloads from the PyChase engine. + + Returns + ------- + list[Finding] + Findings ordered by descending score then location. + """ + findings = [ + Finding( + first=_unit_key(pair["left"]), + second=_unit_key(pair["right"]), + location_first=_location(pair["left"]), + location_second=_location(pair["right"]), + score=float(pair["score"]), + ) + for pair in pairs + ] + findings.sort(key=lambda f: (-f.score, f.location_first, f.location_second)) + return findings + + +def load_allowlist(pyproject_path: Path) -> tuple[AllowEntry, ...]: + """Load the reasoned allow entries from ``[tool.duplication_gate]``. + + Parameters + ---------- + pyproject_path : pathlib.Path + Path to the repository ``pyproject.toml``. + + Returns + ------- + tuple[AllowEntry, ...] + Parsed allow entries in file order. + + Raises + ------ + GateConfigError + If an entry is missing a reason or names neither a unit nor a pair. + """ + with pyproject_path.open("rb") as handle: + data = tomllib.load(handle) + root = _config_mapping(data, context="pyproject") + tool = _config_mapping(root.get("tool", {}), context="pyproject.tool") + table = _config_mapping( + tool.get("duplication_gate", {}), + context="pyproject.tool.duplication_gate", + ) + raw_entries = table.get("allow", ()) + if not isinstance(raw_entries, cabc.Sequence) or isinstance( + raw_entries, (str, bytes) + ): + msg = "duplication_gate.allow must be an array" + raise GateConfigError(msg) + return tuple( + _allow_entry(raw, index=index) for index, raw in enumerate(raw_entries) + ) + + +def _config_mapping(value: object, *, context: str) -> cabc.Mapping[str, object]: + """Validate one TOML table before configuration logic consumes it.""" + if not isinstance(value, cabc.Mapping) or not all( + isinstance(key, str) for key in value + ): + msg = f"{context} must be a table with string keys" + raise GateConfigError(msg) + return typ.cast("cabc.Mapping[str, object]", value) + + +def _allow_entry(raw: object, *, index: int) -> AllowEntry: + """Validate and normalize one reasoned TOML allow entry.""" + table = _config_mapping(raw, context=f"duplication_gate.allow[{index}]") + reason = table.get("reason", "") + if not isinstance(reason, str) or not reason.strip(): + msg = f"duplication_gate.allow[{index}] requires a non-empty reason" + raise GateConfigError(msg) + return AllowEntry(units=_entry_units(table, index), reason=reason) + + +def _entry_units(raw: cabc.Mapping[str, object], index: int) -> tuple[str, ...]: + """Extract and validate the unit keys named by one allow entry.""" + unit = raw.get("unit") + pair = raw.get("pair") + context = f"duplication_gate.allow[{index}]" + if (unit is None) == (pair is None): + msg = f"{context} must set exactly one of 'unit' or 'pair'" + raise GateConfigError(msg) + if unit is not None: + if not isinstance(unit, str) or "::" not in unit: + msg = f"{context} unit must be a 'path::qualname' string" + raise GateConfigError(msg) + return (unit,) + match pair: + case [str() as first, str() as second] if "::" in first and "::" in second: + return (first, second) + case _: + msg = f"{context} pair must be two 'path::qualname' strings" + raise GateConfigError(msg) + + +def partition_findings( + findings: cabc.Sequence[Finding], + allowlist: cabc.Sequence[AllowEntry], +) -> tuple[list[Finding], list[Finding], list[AllowEntry]]: + """Split findings into blocking and allowed, and spot stale entries. + + Parameters + ---------- + findings : collections.abc.Sequence[Finding] + Normalized detector findings. + allowlist : collections.abc.Sequence[AllowEntry] + Reasoned exceptions from the repository configuration. + + Returns + ------- + tuple[list[Finding], list[Finding], list[AllowEntry]] + Blocking findings, silenced findings, and allow entries that no + longer match any finding. + """ + blocking: list[Finding] = [] + allowed: list[Finding] = [] + used: set[int] = set() + for finding in findings: + matched = False + for position, entry in enumerate(allowlist): + if entry.matches(finding.first, finding.second): + used.add(position) + matched = True + (allowed if matched else blocking).append(finding) + stale = [entry for position, entry in enumerate(allowlist) if position not in used] + return blocking, allowed, stale + + +def run_detector() -> list[Finding]: + """Run PyChase with the repository configuration and normalize output.""" + config = Config.from_pyproject(str(PYPROJECT)) + files = _collect_files(config.paths, config.exclude) + report = _detector_mapping(find(files, config), context="PyChase report") + return normalize_findings(_detector_pairs(report.get("pairs"))) + + +def _report( + blocking: list[Finding], allowed: list[Finding], stale: list[AllowEntry] +) -> None: + """Print the gate outcome in a concise, actionable form.""" + for entry in stale: + joined = " ~ ".join(entry.units) + print(f"stale allow entry ({joined}): remove it; the duplication is gone") + if not blocking: + suffix = f"; {len(allowed)} allowed by reasoned exceptions" if allowed else "" + print(f"duplication gate passed{suffix}") + return + print(f"duplicate code: {len(blocking)} unsuppressed pair(s)") + for finding in blocking: + print( + f" {finding.location_first} ~ {finding.location_second} " + f"(similarity {finding.score:.2f})" + ) + print(f" units: {finding.first} ~ {finding.second}") + print( + "Extract the shared logic into one helper, or record a considered " + "exception:\n make duplication-allow FIRST='' " + "[SECOND=''] REASON=''" + ) + + +def _check_inputs( + *, + allowlist_reader: AllowlistReader, + detector: FindingDetector, +) -> tuple[tuple[AllowEntry, ...], list[Finding]]: + """Load the gate inputs with explicit local-environment failures.""" + try: + allowlist = allowlist_reader(PYPROJECT) + except GateConfigError: + raise + except (OSError, tomllib.TOMLDecodeError) as error: + msg = f"cannot load duplication allowlist: {error}" + raise GateExecutionError(msg) from error + try: + findings = detector() + except GateConfigError: + raise + except (OSError, RuntimeError) as error: + msg = f"PyChase detector failed: {error}" + raise GateExecutionError(msg) from error + except (TypeError, ValueError) as error: + raise GateConfigError(str(error)) from error + return allowlist, findings + + +@app.command +def check() -> None: + """Run the blocking duplication gate and exit non-zero on findings.""" + os.chdir(REPO_ROOT) + try: + allowlist, findings = _check_inputs( + allowlist_reader=load_allowlist, + detector=run_detector, + ) + except GateConfigError as error: + print(f"configuration error: {error}", file=sys.stderr) + raise SystemExit(2) from error + blocking, allowed, stale = partition_findings(findings, allowlist) + _report(blocking, allowed, stale) + if blocking: + raise SystemExit(1) + + +@app.command +def allow( + *, + first: str, + second: str | None = None, + reason: str, +) -> None: + """Record one reasoned exception in ``[tool.duplication_gate]``. + + Parameters + ---------- + first : str + Unit key (``path::qualname``) of the first or only member. + second : str | None + Optional second unit key; when set, the entry silences only this + pair rather than every pair involving ``first``. + reason : str + Reviewable justification for keeping the duplication. + + Raises + ------ + SystemExit + If the reason is empty or a unit key is malformed. + """ + if not reason.strip(): + print("REASON must not be empty", file=sys.stderr) + raise SystemExit(2) + for key in (first, second) if second else (first,): + if "::" not in key: + print(f"'{key}' is not a 'path::qualname' unit key", file=sys.stderr) + raise SystemExit(2) + try: + append_allow_entry(PYPROJECT, first=first, second=second, reason=reason) + except GateConfigError as error: + print(f"configuration error: {error}", file=sys.stderr) + raise SystemExit(2) from error + label = first if second is None else f"{first} ~ {second}" + print(f"recorded duplication exception for {label}") + + +def append_allow_entry( + pyproject_path: Path, + *, + first: str, + second: str | None, + reason: str, +) -> None: + """Append one allow entry to ``[tool.duplication_gate]`` in place. + + Parameters + ---------- + pyproject_path : pathlib.Path + Path to the ``pyproject.toml`` to update. + first : str + Unit key of the first or only member. + second : str | None + Optional second unit key for a pair entry. + reason : str + Reviewable justification recorded with the entry. + """ + units = (first,) if second is None else (first, second) + with _locked_file(pyproject_path): + document = tomlkit.parse(pyproject_path.read_text(encoding="utf-8")) + tool = document.setdefault("tool", tomlkit.table(is_super_table=True)) + gate = tool.setdefault("duplication_gate", tomlkit.table()) + entries = gate.setdefault("allow", tomlkit.aot()) + for index, raw_entry in enumerate(entries): + existing = _allow_entry(raw_entry, index=index) + if _same_allow_target(existing.units, units): + raw_entry["reason"] = reason + atomic_write( + pyproject_path, + tomlkit.dumps(document).encode("utf-8"), + create_parents=False, + preserve_mode=True, + sync_file=True, + ) + return + entry = tomlkit.table() + if second is None: + entry["unit"] = first + else: + entry["pair"] = [first, second] + entry["reason"] = reason + entries.append(entry) + atomic_write( + pyproject_path, + tomlkit.dumps(document).encode("utf-8"), + create_parents=False, + preserve_mode=True, + sync_file=True, + ) + + +def _same_allow_target(left: tuple[str, ...], right: tuple[str, ...]) -> bool: + """Report whether two unit or unordered-pair entries name the same target.""" + return left == right or ( + len(left) == len(right) == _PAIR_MEMBER_COUNT and set(left) == set(right) + ) + + +@contextmanager +def _locked_file(path: Path) -> cabc.Iterator[None]: + """Hold an advisory cross-process lock while replacing ``path``.""" + lock_path = path.with_name(f".{path.name}.duplication-gate.lock") + with lock_path.open("a+", encoding="utf-8") as lock: + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(lock.fileno(), fcntl.LOCK_UN) + + +def _ensure_deterministic_hashing( + environment: cabc.MutableMapping[str, str] | None = None, +) -> None: + """Re-exec with a fixed hash seed so LSH bucketing is reproducible. + + PyChase buckets MinHash signatures with the built-in ``hash()`` over + strings, which Python randomizes per process unless ``PYTHONHASHSEED`` + is pinned. Without this, near-threshold pairs appear and disappear + between runs, which a blocking gate cannot tolerate. + """ + environment = os.environ if environment is None else environment + if environment.get("PYTHONHASHSEED") != "0": + environment["PYTHONHASHSEED"] = "0" + os.execv(sys.executable, [sys.executable, *sys.argv]) # noqa: S606 - re-exec of the same interpreter with a pinned hash seed + + +if __name__ == "__main__": + _ensure_deterministic_hashing() + app() diff --git a/scripts/tests/conftest.py b/scripts/tests/conftest.py new file mode 100644 index 00000000..3216220e --- /dev/null +++ b/scripts/tests/conftest.py @@ -0,0 +1,8 @@ +"""Configure imports for duplication-gate script tests.""" + +import sys +from pathlib import Path + +SCRIPT_DIRECTORY = Path(__file__).resolve().parents[1] +if str(SCRIPT_DIRECTORY) not in sys.path: + sys.path.append(str(SCRIPT_DIRECTORY)) diff --git a/scripts/tests/duplication_gate_test_support.py b/scripts/tests/duplication_gate_test_support.py new file mode 100644 index 00000000..ba9e2d4b --- /dev/null +++ b/scripts/tests/duplication_gate_test_support.py @@ -0,0 +1,66 @@ +"""Shared subprocess support for duplication-gate workflow tests.""" + +import os +import shutil +import subprocess # noqa: S404 - support invokes fixed test commands. +import sys +from pathlib import Path + +import pytest + +try: + import duplication_gate as _gate +except ImportError: # pragma: no cover - Python 3.14 path + pytest.skip( + "duplication_gate requires PyChase, which needs Python < 3.14", + allow_module_level=True, + ) +except AttributeError as error: # pragma: no cover - Python 3.14 path + if str(error) != "module 'ast' has no attribute 'Str'": + raise + pytest.skip( + "duplication_gate requires PyChase, which needs Python < 3.14", + allow_module_level=True, + ) + +gate = _gate + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] + + +def copied_gate_workspace(tmp_path: Path) -> tuple[Path, Path]: + """Create a mutable workspace containing the gate and its shared writer.""" + workspace = tmp_path / "gate-workspace" + scripts = workspace / "scripts" + scripts.mkdir(parents=True) + for name in ("duplication_gate.py", "typos_rollout_cache.py"): + shutil.copy(REPOSITORY_ROOT / "scripts" / name, scripts / name) + (workspace / "pyproject.toml").write_text( + '[project]\nname = "gate-test"\nversion = "0"\n', encoding="utf-8" + ) + return workspace, scripts / "duplication_gate.py" + + +def gate_command(script: Path, *arguments: str) -> list[str]: + """Build an isolated Python command for a copied gate script.""" + return [sys.executable, str(script), *arguments] + + +def gate_environment() -> dict[str, str]: + """Build a deterministic environment for gate subprocesses.""" + return {**os.environ, "PYTHONHASHSEED": "0"} + + +def run_gate_command( + script: Path, + *arguments: str, +) -> subprocess.CompletedProcess[str]: + """Run a copied gate command and capture its completed result.""" + return subprocess.run( # noqa: S603 - fixed test interpreter and copied script. + gate_command(script, *arguments), + cwd=script.parent.parent, + env=gate_environment(), + check=False, + capture_output=True, + text=True, + ) diff --git a/scripts/tests/test_duplication_gate.py b/scripts/tests/test_duplication_gate.py new file mode 100644 index 00000000..3427b8f6 --- /dev/null +++ b/scripts/tests/test_duplication_gate.py @@ -0,0 +1,384 @@ +"""Tests for the code-duplication gate helper script. + +The gate depends on PyChase, which imports removed ``ast`` aliases on +Python 3.14, so the whole module is skipped when that import fails; the +``make duplication-test`` target runs these tests on Python 3.13. +""" + +import re +import textwrap +import typing as typ + +import pytest + +if typ.TYPE_CHECKING: + from pathlib import Path + +try: + import duplication_gate as gate +except ImportError: # pragma: no cover - Python 3.14 path + pytest.skip( + "duplication_gate requires PyChase, which needs Python < 3.14", + allow_module_level=True, + ) +except AttributeError as error: # pragma: no cover - Python 3.14 path + if str(error) != "module 'ast' has no attribute 'Str'": + raise + pytest.skip( + "duplication_gate requires PyChase, which needs Python < 3.14", + allow_module_level=True, + ) + + +def _finding( + first: str = "episodic/a.py::alpha", + second: str = "episodic/b.py::beta", + score: float = 1.0, +) -> gate.Finding: + """Build a finding with derived locations for partitioning tests.""" + return gate.Finding( + first=first, + second=second, + location_first=f"{first.split('::')[0]}:1-20", + location_second=f"{second.split('::')[0]}:1-20", + score=score, + ) + + +class TestAllowEntry: + """Matching semantics for unit and pair allow entries.""" + + def test_unit_entry_matches_either_side(self) -> None: + """A unit entry silences any pair the unit participates in.""" + entry = gate.AllowEntry(units=("episodic/a.py::alpha",), reason="r") + assert entry.matches("episodic/a.py::alpha", "episodic/b.py::beta"), ( + "Unit entry must match its left member." + ) + assert entry.matches("episodic/b.py::beta", "episodic/a.py::alpha"), ( + "Unit entry must match its right member." + ) + assert not entry.matches("episodic/b.py::beta", "episodic/c.py::gamma"), ( + "Unit entry must not match unrelated members." + ) + + def test_pair_entry_matches_unordered(self) -> None: + """A pair entry silences only that unordered pair.""" + entry = gate.AllowEntry( + units=("episodic/a.py::alpha", "episodic/b.py::beta"), + reason="r", + ) + assert entry.matches("episodic/b.py::beta", "episodic/a.py::alpha"), ( + "Pair entry must ignore member order." + ) + assert not entry.matches("episodic/a.py::alpha", "episodic/c.py::gamma"), ( + "Pair entry must not match a different pair." + ) + + +class TestNormalizeFindings: + """Normalization of raw PyChase pair payloads.""" + + def test_orders_by_descending_score_then_location(self) -> None: + """Findings sort by score, then by source location.""" + raw: list[gate._DetectorPairPayload] = [ + { + "score": 0.9, + "left": { + "file": "episodic/z.py", + "start_line": 1, + "end_line": 20, + "qualname": "one", + }, + "right": { + "file": "episodic/z.py", + "start_line": 30, + "end_line": 50, + "qualname": "two", + }, + }, + { + "score": 1.0, + "left": { + "file": "episodic/a.py", + "start_line": 5, + "end_line": 25, + "qualname": "three", + }, + "right": { + "file": "episodic/b.py", + "start_line": 5, + "end_line": 25, + "qualname": "four", + }, + }, + ] + findings = gate.normalize_findings(raw) + assert [f.score for f in findings] == [1.0, 0.9], ( + "Findings must descend by similarity." + ) + assert findings[0].first == "episodic/a.py::three", ( + "Higher-scored finding must sort first." + ) + assert findings[0].location_first == "episodic/a.py:5-25", ( + "Normalized location must retain source lines." + ) + + +class TestDetectorMember: + """PyChase member payload validation.""" + + _VALID_PAYLOAD: typ.ClassVar[dict[str, object]] = { + "file": "episodic/a.py", + "qualname": "module.function", + "start_line": 10, + "end_line": 20, + } + _MISSING = object() + + def test_accepts_a_valid_payload(self) -> None: + """A complete PyChase member retains its original field values.""" + assert ( + gate._detector_member(self._VALID_PAYLOAD, context="member") + == self._VALID_PAYLOAD + ), "Valid PyChase member payloads must round-trip." + + @pytest.mark.parametrize( + ("field", "invalid_value", "expected_error", "message"), + [ + ( + "file", + _MISSING, + ValueError, + "member.file must be a non-empty string", + ), + ("file", "", ValueError, "member.file must be a non-empty string"), + ( + "file", + 1, + ValueError, + "member.file must be a non-empty string", + ), + ( + "qualname", + _MISSING, + ValueError, + "member.qualname must be a non-empty string", + ), + ( + "qualname", + "", + ValueError, + "member.qualname must be a non-empty string", + ), + ( + "qualname", + 1, + ValueError, + "member.qualname must be a non-empty string", + ), + ( + "start_line", + True, + TypeError, + "member.start_line must be a positive integer", + ), + ( + "start_line", + "10", + TypeError, + "member.start_line must be a positive integer", + ), + ( + "end_line", + False, + TypeError, + "member.end_line must not precede start_line", + ), + ( + "start_line", + 0, + ValueError, + "member.start_line must be a positive integer", + ), + ( + "start_line", + -1, + ValueError, + "member.start_line must be a positive integer", + ), + ( + "end_line", + "20", + TypeError, + "member.end_line must not precede start_line", + ), + ( + "end_line", + _MISSING, + TypeError, + "member.end_line must not precede start_line", + ), + ("end_line", 9, ValueError, "member.end_line must not precede start_line"), + ], + ids=[ + "missing-file", + "empty-file", + "non-string-file", + "missing-qualname", + "empty-qualname", + "non-string-qualname", + "boolean-start-line", + "non-integer-start-line", + "boolean-end-line", + "zero-start-line", + "negative-start-line", + "non-integer-end-line", + "missing-end-line", + "inverted-lines", + ], + ) + def test_rejects_invalid_field_values( + self, + field: str, + invalid_value: object, + expected_error: type[Exception], + message: str, + ) -> None: + """Invalid fields preserve PyChase's exception types and messages.""" + payload: dict[str, object] = self._VALID_PAYLOAD.copy() + if invalid_value is self._MISSING: + del payload[field] + else: + payload[field] = invalid_value + with pytest.raises(expected_error) as error: + gate._detector_member(payload, context="member") + assert type(error.value) is expected_error, "Exception type must remain exact." + assert str(error.value) == message, "Validation message must remain exact." + + def test_rejects_non_mapping_payload(self) -> None: + """Non-object PyChase members fail at the detector boundary.""" + with pytest.raises( + TypeError, + match=re.escape("member must be an object with string keys"), + ): + gate._detector_member([], context="member") + + +class TestLoadAllowlist: + """Allowlist parsing and validation.""" + + def _write(self, tmp_path: object, body: str) -> object: + """Write ``body`` to ``pyproject.toml`` under ``tmp_path`` and return it.""" + pyproject = typ.cast("Path", tmp_path) / "pyproject.toml" + pyproject.write_text(textwrap.dedent(body), encoding="utf-8") + return pyproject + + def test_loads_unit_and_pair_entries(self, tmp_path: object) -> None: + """Unit and pair entries load with their reasons.""" + pyproject = typ.cast( + "Path", + self._write( + tmp_path, + """\ + [[tool.duplication_gate.allow]] + unit = "episodic/a.py::alpha" + reason = "declarative" + + [[tool.duplication_gate.allow]] + pair = ["episodic/b.py::beta", "episodic/c.py::gamma"] + reason = "parallel contracts" + """, + ), + ) + entries = gate.load_allowlist(pyproject) + assert entries[0].units == ("episodic/a.py::alpha",), ( + "Unit entry must retain its target." + ) + assert entries[1].units == ("episodic/b.py::beta", "episodic/c.py::gamma"), ( + "Pair entry must retain both targets." + ) + assert entries[1].reason == "parallel contracts", ( + "Allow entry must retain its reason." + ) + + def test_missing_gate_table_yields_empty_allowlist(self, tmp_path: object) -> None: + """A pyproject without the gate table produces no entries.""" + pyproject = typ.cast( + "Path", + self._write(tmp_path, "[project]\nname = 'x'\nversion = '0'\n"), + ) + assert gate.load_allowlist(pyproject) == (), ( + "Missing gate table must mean no allow entries." + ) + + @pytest.mark.parametrize( + ("body", "diagnostic"), + [ + ( + '[[tool.duplication_gate.allow]]\nunit = "episodic/a.py::alpha"\n', + "requires a non-empty reason", + ), + ( + '[[tool.duplication_gate.allow]]\nunit = "a"\nreason = "r"\n', + "unit must be a 'path::qualname' string", + ), + ( + '[[tool.duplication_gate.allow]]\npair = ["a.py::x"]\nreason = "r"\n', + "pair must be two 'path::qualname' strings", + ), + ( + '[[tool.duplication_gate.allow]]\nreason = "r"\n', + "must set exactly one of 'unit' or 'pair'", + ), + ( + ( + '[[tool.duplication_gate.allow]]\nunit = "episodic/a.py::x"\n' + 'pair = ["episodic/a.py::x", "episodic/b.py::y"]\nreason = "r"\n' + ), + "must set exactly one of 'unit' or 'pair'", + ), + ], + ids=[ + "no-reason", + "malformed-unit", + "one-member-pair", + "no-target", + "both-kinds", + ], + ) + def test_rejects_malformed_entries( + self, + tmp_path: object, + body: str, + diagnostic: str, + ) -> None: + """Malformed entries raise a configuration error.""" + pyproject = typ.cast("Path", self._write(tmp_path, body)) + with pytest.raises(gate.GateConfigError, match=re.escape(diagnostic)): + gate.load_allowlist(pyproject) + + +class TestPartitionFindings: + """Blocking, allowed, and stale-entry partitioning.""" + + def test_unmatched_findings_block(self) -> None: + """A finding with no matching entry blocks the gate.""" + blocking, allowed, stale = gate.partition_findings([_finding()], []) + assert len(blocking) == 1, "Unmatched finding must block." + assert not allowed, "Unmatched finding must not be allowed." + assert not stale, "Empty allowlist must not yield stale entries." + + def test_matched_findings_are_allowed(self) -> None: + """Entries silence their findings and are not reported stale.""" + entry = gate.AllowEntry(units=("episodic/a.py::alpha",), reason="r") + blocking, allowed, stale = gate.partition_findings([_finding()], [entry]) + assert not blocking, "Matching entry must prevent blocking." + assert len(allowed) == 1, "Matching entry must allow the finding." + assert not stale, "Used entry must not be stale." + + def test_unused_entries_are_stale(self) -> None: + """Entries matching nothing are reported for removal.""" + entry = gate.AllowEntry(units=("episodic/gone.py::old",), reason="r") + blocking, _allowed, stale = gate.partition_findings([_finding()], [entry]) + assert len(blocking) == 1, "Unmatched finding must remain blocking." + assert stale == [entry], "Unused allow entry must be stale." diff --git a/scripts/tests/test_duplication_gate_commands.py b/scripts/tests/test_duplication_gate_commands.py new file mode 100644 index 00000000..230ab064 --- /dev/null +++ b/scripts/tests/test_duplication_gate_commands.py @@ -0,0 +1,353 @@ +"""Command and Make workflow tests for the duplication gate.""" + +import shutil +import subprocess # noqa: S404 - tests exercise copied gate and Make commands. +import sys +import textwrap +import typing as typ +from pathlib import Path + +import pytest +from duplication_gate_test_support import ( + REPOSITORY_ROOT, + copied_gate_workspace, + gate, + gate_environment, + run_gate_command, +) + + +def _finding() -> gate.Finding: + """Build a representative blocking finding.""" + return gate.Finding( + first="episodic/a.py::alpha", + second="episodic/b.py::beta", + location_first="episodic/a.py:1-20", + location_second="episodic/b.py:1-20", + score=1.0, + ) + + +def _make_allow( + workspace: object, + *, + first: str | None, + second: str | None, + reason: str | None, + environment: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + """Run the real Make target against a copied, writable gate workspace.""" + make = shutil.which("make") + assert make is not None, "Expected make to be available for contract tests." + command = [ + make, + "--no-print-directory", + "-f", + str(REPOSITORY_ROOT / "Makefile"), + "duplication-allow", + f"DUPLICATION_GATE={sys.executable} scripts/duplication_gate.py", + ] + if first is not None: + command.append(f"FIRST={first}") + if second is not None: + command.append(f"SECOND={second}") + if reason is not None: + command.append(f"REASON={reason}") + workspace_path = Path(typ.cast("Path", workspace)) + return subprocess.run( # noqa: S603 - fixed Make target and copied workspace. + command, + cwd=workspace_path, + env=gate_environment() if environment is None else environment, + check=False, + capture_output=True, + text=True, + ) + + +class TestGateCommands: + """CLI orchestration and real workflow contracts.""" + + def test_check_reports_blocking_findings( + self, + tmp_path: object, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """The check command emits the blocking report and status one.""" + monkeypatch.chdir(typ.cast("Path", tmp_path)) + monkeypatch.setattr(gate, "load_allowlist", lambda _path: ()) + monkeypatch.setattr(gate, "run_detector", lambda: [_finding()]) + with pytest.raises(SystemExit) as error: + gate.check() + assert error.value.code == 1, "Blocking findings must return status one." + assert capsys.readouterr().out == ( + "duplicate code: 1 unsuppressed pair(s)\n" + " episodic/a.py:1-20 ~ episodic/b.py:1-20 (similarity 1.00)\n" + " units: episodic/a.py::alpha ~ episodic/b.py::beta\n" + "Extract the shared logic into one helper, or record a considered " + "exception:\n" + " make duplication-allow FIRST='' " + "[SECOND=''] REASON=''\n" + ), "Blocking report must remain actionable and deterministic." + + @pytest.mark.parametrize( + "error", + [ + pytest.param(OSError("unreadable configuration"), id="allowlist-io"), + pytest.param(OSError("detector executable unavailable"), id="detector-io"), + pytest.param( + RuntimeError("detector runtime failed"), id="detector-runtime" + ), + ], + ) + def test_check_inputs_wrap_environment_failures(self, error: Exception) -> None: + """Injected reader and detector failures become explicit gate errors.""" + if str(error).startswith("unreadable"): + + def reader(_path: object) -> tuple[gate.AllowEntry, ...]: + raise error + + def detector() -> list[gate.Finding]: + return [] + + else: + + def reader(_path: object) -> tuple[gate.AllowEntry, ...]: + return () + + def detector() -> list[gate.Finding]: + raise error + + with pytest.raises(gate.GateExecutionError, match=str(error)): + gate._check_inputs(allowlist_reader=reader, detector=detector) + + def test_check_reports_detector_schema_errors( + self, + tmp_path: object, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Malformed detector reports exit cleanly instead of showing a traceback.""" + monkeypatch.chdir(typ.cast("Path", tmp_path)) + monkeypatch.setattr(gate, "load_allowlist", lambda _path: ()) + + def raise_schema_error() -> list[gate.Finding]: + msg = "PyChase report pairs must be an array" + raise TypeError(msg) + + monkeypatch.setattr(gate, "run_detector", raise_schema_error) + with pytest.raises(SystemExit) as error: + gate.check() + + assert error.value.code == 2, "Malformed detector reports must return two." + assert capsys.readouterr().err == ( + "configuration error: PyChase report pairs must be an array\n" + ), "Schema errors must use the configuration diagnostic." + + def test_allow_reports_malformed_existing_entries( + self, + tmp_path: object, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Malformed existing allows exit cleanly instead of showing a traceback.""" + pyproject = typ.cast("Path", tmp_path) / "pyproject.toml" + pyproject.write_text( + '[[tool.duplication_gate.allow]]\nunit = "episodic/a.py::alpha"\n', + encoding="utf-8", + ) + monkeypatch.setattr(gate, "PYPROJECT", pyproject) + + with pytest.raises(SystemExit) as error: + gate.allow( + first="episodic/b.py::beta", + reason="reviewed exception", + ) + + assert error.value.code == 2, "Malformed existing allows must return two." + assert capsys.readouterr().err == ( + "configuration error: duplication_gate.allow[0] " + "requires a non-empty reason\n" + ), "Malformed allows must use the configuration diagnostic." + + def test_deterministic_hashing_reexecs_once( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An unpinned process re-execs itself with a deterministic hash seed.""" + calls: list[tuple[str, list[str]]] = [] + environment = dict(gate.os.environ) + environment.pop("PYTHONHASHSEED", None) + monkeypatch.setattr( + gate.os, + "execv", + lambda executable, arguments: calls.append((executable, arguments)), + ) + gate._ensure_deterministic_hashing(environment) + assert environment["PYTHONHASHSEED"] == "0", "Re-exec must pin the hash seed." + assert calls == [ + (gate.sys.executable, [gate.sys.executable, *gate.sys.argv]) + ], "Re-exec must retain the current interpreter and arguments." + + @pytest.mark.parametrize( + ("second", "expected_units"), + [ + pytest.param(None, ("episodic/a.py::alpha",), id="unit"), + pytest.param( + "episodic/b.py::beta", + ("episodic/a.py::alpha", "episodic/b.py::beta"), + id="pair", + ), + ], + ) + def test_allow_cli_round_trips_unit_and_pair( + self, + tmp_path: object, + second: str | None, + expected_units: tuple[str, ...], + ) -> None: + """The real allow CLI records both supported exception forms.""" + _workspace, script = copied_gate_workspace(typ.cast("Path", tmp_path)) + arguments = ["allow", "--first", "episodic/a.py::alpha"] + if second is not None: + arguments.extend(("--second", second)) + arguments.extend(("--reason", "reviewed exception")) + + result = run_gate_command(script, *arguments) + assert result.returncode == 0, result.stderr + entries = gate.load_allowlist(script.parent.parent / "pyproject.toml") + assert entries[0].units == expected_units, ( + "CLI must retain its requested units." + ) + assert entries[0].reason == "reviewed exception", ( + "CLI must retain the supplied reason." + ) + + @pytest.mark.parametrize( + ("first", "reason", "expected_error"), + [ + pytest.param(None, "reviewed exception", "FIRST is required", id="first"), + pytest.param( + "episodic/a.py::alpha", None, "REASON is required", id="reason" + ), + ], + ) + def test_make_duplication_allow_rejects_missing_and_ambient_values( + self, + tmp_path: object, + first: str | None, + reason: str | None, + expected_error: str, + ) -> None: + """Only command-line values satisfy the Make target's required inputs.""" + workspace, _script = copied_gate_workspace(typ.cast("Path", tmp_path)) + environment = { + **gate_environment(), + "FIRST": "episodic/ambient.py::first", + "SECOND": "episodic/ambient.py::second", + "REASON": "ambient reason", + } + result = _make_allow( + workspace, + first=first, + second=None, + reason=reason, + environment=environment, + ) + assert result.returncode == 2, result.stderr + assert expected_error in result.stderr, ( + "Make must reject ambient values for required arguments." + ) + + @pytest.mark.parametrize( + ("second", "expected_units"), + [ + pytest.param(None, ("episodic/a.py::alpha",), id="unit"), + pytest.param( + "episodic/b.py::beta", + ("episodic/a.py::alpha", "episodic/b.py::beta"), + id="pair", + ), + ], + ) + def test_make_duplication_allow_round_trips_quoted_values( + self, + tmp_path: object, + second: str | None, + expected_units: tuple[str, ...], + ) -> None: + """The Make target forwards unit and pair inputs as literal arguments.""" + workspace, _script = copied_gate_workspace(typ.cast("Path", tmp_path)) + marker = typ.cast("Path", tmp_path) / "injected-command" + reason = f'kept literally: "$(touch {marker})"; $HOME' + result = _make_allow( + workspace, + first="episodic/a.py::alpha", + second=second, + reason=reason, + ) + assert result.returncode == 0, result.stderr + entries = gate.load_allowlist(workspace / "pyproject.toml") + assert entries[0].units == expected_units, ( + "Make must forward the requested unit or pair exactly." + ) + assert entries[0].reason == reason, "Make must preserve quoted reasons." + assert not marker.exists(), "Quoted values must not execute shell fragments." + + def test_real_check_cli_passes(self) -> None: + """The checked-in gate runs successfully through its real CLI boundary.""" + result = subprocess.run( # noqa: S603 - fixed repository gate command. + [ + sys.executable, + str(REPOSITORY_ROOT / "scripts" / "duplication_gate.py"), + "check", + ], + cwd=REPOSITORY_ROOT, + env=gate_environment(), + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert "duplication gate passed" in result.stdout, ( + "Real check invocation must report its successful gate result." + ) + + def test_reports_a_planted_verbatim_copy(self, tmp_path: object) -> None: + """The pinned engine reports a planted copy through normalization.""" + body = textwrap.dedent( + """\ + def NAME(items): + total = 0.0 + for item in items: + price = item["price"] * item["quantity"] + if item.get("taxable"): + price *= 1.2 + if item.get("discount"): + price -= item["discount"] + total += price + if total < 0: + total = 0.0 + return round(total, 2) + """ + ) + module = typ.cast("Path", tmp_path) / "mod.py" + module.write_text( + body.replace("NAME", "first_total") + + "\n\n" + + body.replace("NAME", "second_total"), + encoding="utf-8", + ) + from pychase.cli import ( # ty: ignore[unresolved-import] # pychase installs only in the gate's Python 3.13 environment. + Config, + ) + from pychase.engine import ( # ty: ignore[unresolved-import] # pychase installs only in the gate's Python 3.13 environment. + find, + ) + + config = Config() + config.threshold = 0.9 + config.min_lines = 5 + config.min_nodes = 10 + findings = gate.normalize_findings(find([str(module)], config)["pairs"]) + assert len(findings) == 1, "Planted copy must produce one pair." + assert findings[0].score == 1.0, "Verbatim copy must have perfect similarity." diff --git a/scripts/tests/test_duplication_gate_persistence.py b/scripts/tests/test_duplication_gate_persistence.py new file mode 100644 index 00000000..e8efb4a1 --- /dev/null +++ b/scripts/tests/test_duplication_gate_persistence.py @@ -0,0 +1,143 @@ +"""Persistence and contention tests for duplication-gate allow entries.""" + +import subprocess # noqa: S404 - tests exercise copied gate commands. +import tomllib +from pathlib import Path + +import pytest +from duplication_gate_test_support import ( + copied_gate_workspace, + gate, + gate_command, + gate_environment, +) + + +class TestAppendAllowEntry: + """Persisting reasoned entries to ``pyproject.toml``.""" + + def test_round_trips_unit_and_pair_entries(self, tmp_path: Path) -> None: + """Appended entries load again and preserve existing TOML content.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "x"\nversion = "0"\n', encoding="utf-8") + gate.append_allow_entry( + pyproject, + first="episodic/a.py::alpha", + second=None, + reason="unit reason", + ) + gate.append_allow_entry( + pyproject, + first="episodic/b.py::beta", + second="episodic/c.py::gamma", + reason="pair reason", + ) + gate.append_allow_entry( + pyproject, + first="episodic/c.py::gamma", + second="episodic/b.py::beta", + reason="updated pair reason", + ) + + entries = gate.load_allowlist(pyproject) + assert entries[0].units == ("episodic/a.py::alpha",), ( + "Unit entry must retain its target." + ) + assert entries[1].units == ("episodic/b.py::beta", "episodic/c.py::gamma"), ( + "Pair entry must retain both targets." + ) + assert entries[1].reason == "updated pair reason", ( + "Repeated pair must update its reason." + ) + assert len(entries) == 2, "Repeated pair must not create a duplicate entry." + data = tomllib.loads(pyproject.read_text(encoding="utf-8")) + assert data["project"]["name"] == "x", ( + "Appending must preserve existing TOML content." + ) + + def test_atomic_write_preserves_mode_and_original_on_failure( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Failed replacement retains contents, mode, and a usable lock.""" + pyproject = tmp_path / "pyproject.toml" + original = '[project]\nname = "x"\nversion = "0"\n' + pyproject.write_text(original, encoding="utf-8") + pyproject.chmod(0o640) + original_replace = Path.replace + + def fail_replace(_source: Path, _destination: Path) -> None: + msg = "replacement failed" + raise OSError(msg) + + monkeypatch.setattr(Path, "replace", fail_replace) + with pytest.raises(OSError, match="replacement failed"): + gate.append_allow_entry( + pyproject, + first="episodic/a.py::alpha", + second=None, + reason="unit reason", + ) + monkeypatch.setattr(Path, "replace", original_replace) + + assert pyproject.read_text(encoding="utf-8") == original, ( + "Failed replacement must preserve the original TOML." + ) + gate.append_allow_entry( + pyproject, + first="episodic/a.py::alpha", + second=None, + reason="unit reason", + ) + assert pyproject.stat().st_mode & 0o777 == 0o640, ( + "Replacement must preserve the destination mode." + ) + + def test_concurrent_allow_commands_preserve_both_entries( + self, tmp_path: Path + ) -> None: + """Two blocked writers retain both exceptions after the lock releases.""" + _, script = copied_gate_workspace(tmp_path) + with gate._locked_file(script.parent.parent / "pyproject.toml"): + first = subprocess.Popen( # noqa: S603 - fixed copied gate command. + gate_command( + script, + "allow", + "--first", + "episodic/a.py::alpha", + "--reason", + "first writer", + ), + cwd=script.parent.parent, + env=gate_environment(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + second = subprocess.Popen( # noqa: S603 - fixed copied gate command. + gate_command( + script, + "allow", + "--first", + "episodic/b.py::beta", + "--reason", + "second writer", + ), + cwd=script.parent.parent, + env=gate_environment(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + assert first.poll() is None, "First writer must wait for the lock." + assert second.poll() is None, "Second writer must wait for the lock." + + assert first.wait(timeout=10) == 0, "First writer must exit successfully." + assert second.wait(timeout=10) == 0, "Second writer must exit successfully." + + entries = tomllib.loads( + (script.parent.parent / "pyproject.toml").read_text(encoding="utf-8") + )["tool"]["duplication_gate"]["allow"] + recorded_units = {entry["unit"] for entry in entries} + assert recorded_units == {"episodic/a.py::alpha", "episodic/b.py::beta"}, ( + "Concurrent commands must preserve both independently added entries." + ) diff --git a/scripts/tests/test_duplication_gate_properties.py b/scripts/tests/test_duplication_gate_properties.py new file mode 100644 index 00000000..d09114b4 --- /dev/null +++ b/scripts/tests/test_duplication_gate_properties.py @@ -0,0 +1,142 @@ +"""Property tests for duplication-gate matching and normalization invariants.""" + +import itertools + +from duplication_gate_test_support import gate +from hypothesis import given +from hypothesis import strategies as st + +_UNIT_KEYS = st.sampled_from(( + "episodic/a.py::alpha", + "episodic/b.py::beta", + "episodic/c.py::gamma", + "episodic/d.py::delta", +)) + + +def _finding(first: str, second: str, score: float = 1.0) -> gate.Finding: + """Build a finding with stable spans for matching properties.""" + return gate.Finding( + first=first, + second=second, + location_first=f"{first.split('::')[0]}:1-20", + location_second=f"{second.split('::')[0]}:30-50", + score=score, + ) + + +@given(first=_UNIT_KEYS, second=_UNIT_KEYS) +def test_unit_allows_match_either_member(first: str, second: str) -> None: + """A unit allow entry is invariant to finding member order.""" + entry = gate.AllowEntry(units=(first,), reason="property") + assert entry.matches(first, second), ( + "Unit allows must match their first occurrence." + ) + assert entry.matches(second, first), ( + "Unit allows must match their reversed occurrence." + ) + + +@given( + first=_UNIT_KEYS, + second=_UNIT_KEYS.filter(lambda value: value != "episodic/a.py::alpha"), +) +def test_pair_allows_match_only_their_unordered_members( + first: str, + second: str, +) -> None: + """A pair allow matches both orders and no third member.""" + entry = gate.AllowEntry(units=(first, second), reason="property") + assert entry.matches(first, second), "Pair allows must match their stored order." + assert entry.matches(second, first), "Pair allows must match reversed order." + if second != "episodic/a.py::alpha": + assert not entry.matches(first, "episodic/a.py::alpha"), ( + "Pair allows must not match a different unordered pair." + ) + + +@st.composite +def _allow_entries(draw: st.DrawFn) -> list[gate.AllowEntry]: + """Build valid unit and unordered-pair allow entries.""" + entries: list[gate.AllowEntry] = [] + for index in range(draw(st.integers(min_value=0, max_value=8))): + units = draw( + st.one_of( + st.tuples(_UNIT_KEYS), + st.lists(_UNIT_KEYS, min_size=2, max_size=2, unique=True).map(tuple), + ) + ) + entries.append(gate.AllowEntry(units=units, reason=f"property-{index}")) + return entries + + +@given( + pairs=st.lists( + st.lists(_UNIT_KEYS, min_size=2, max_size=2, unique=True).map(tuple), + max_size=12, + ), + allowlist=_allow_entries(), +) +def test_partition_conserves_findings_and_identifies_stale_entries( + pairs: list[tuple[str, str]], + allowlist: list[gate.AllowEntry], +) -> None: + """Every finding is exactly blocking or allowed and stale entries match none.""" + findings = list(itertools.starmap(_finding, pairs)) + blocking, allowed, stale = gate.partition_findings(findings, allowlist) + + assert len(blocking) + len(allowed) == len(findings), ( + "Partitioning must retain every reported finding exactly once." + ) + assert all( + any(entry.matches(finding.first, finding.second) for entry in allowlist) + for finding in allowed + ), "Allowed findings must have a matching allow entry." + assert all( + not any(entry.matches(finding.first, finding.second) for entry in allowlist) + for finding in blocking + ), "Blocking findings must have no matching allow entry." + assert all( + not any(entry.matches(finding.first, finding.second) for finding in findings) + for entry in stale + ), "Stale entries must not match any current finding." + + +@given( + scores=st.lists(st.integers(min_value=0, max_value=100), min_size=1, max_size=12), + locations=st.permutations(("episodic/a.py", "episodic/b.py", "episodic/c.py")), +) +def test_normalization_orders_scores_then_locations( + scores: list[int], + locations: tuple[str, ...], +) -> None: + """Normalized findings use the documented deterministic ordering.""" + pairs: list[gate._DetectorPairPayload] = [] + location_cycle = itertools.cycle(locations) + for index, score in enumerate(scores, start=1): + left_path = next(location_cycle) + right_path = next(location_cycle) + pairs.append({ + "score": float(score), + "left": { + "file": left_path, + "qualname": f"left_{index}", + "start_line": index, + "end_line": index + 1, + }, + "right": { + "file": right_path, + "qualname": f"right_{index}", + "start_line": index + 10, + "end_line": index + 11, + }, + }) + + findings = gate.normalize_findings(pairs) + sort_keys = [ + (-finding.score, finding.location_first, finding.location_second) + for finding in findings + ] + assert sort_keys == sorted(sort_keys), ( + "Normalization must sort by descending score then source locations." + ) diff --git a/scripts/typos_rollout_cache.py b/scripts/typos_rollout_cache.py index b10474b6..08c4de68 100644 --- a/scripts/typos_rollout_cache.py +++ b/scripts/typos_rollout_cache.py @@ -2,6 +2,7 @@ import collections.abc as cabc import dataclasses as dc +import os import pathlib import tempfile import typing as typ @@ -34,15 +35,45 @@ def read(self) -> bytes: ... -def atomic_write(path: pathlib.Path, content: bytes) -> None: - """Write content beside a path and atomically replace the destination.""" - path.parent.mkdir(parents=True, exist_ok=True) +def atomic_write( + path: pathlib.Path, + content: bytes, + *, + create_parents: bool = True, + preserve_mode: bool = False, + sync_file: bool = False, +) -> None: + """Atomically replace a path after writing a temporary sibling. + + Parameters + ---------- + path : pathlib.Path + Destination to replace. + content : bytes + Complete replacement contents. + create_parents : bool + Whether to create missing destination directories. + preserve_mode : bool + Whether an existing destination's permission mode is copied to the + temporary replacement before it is installed. + sync_file : bool + Whether to fsync the temporary replacement before atomically replacing + the destination. + """ + if create_parents: + path.parent.mkdir(parents=True, exist_ok=True) + mode = path.stat().st_mode if preserve_mode else None with tempfile.NamedTemporaryFile( delete=False, dir=path.parent, prefix=f".{path.name}." ) as stream: stream.write(content) + stream.flush() + if sync_file: + os.fsync(stream.fileno()) temporary = pathlib.Path(stream.name) try: + if mode is not None: + temporary.chmod(mode) temporary.replace(path) finally: temporary.unlink(missing_ok=True) diff --git a/tests/canonical_storage/test_generation_run_claims.py b/tests/canonical_storage/test_generation_run_claims.py index 971e7bac..20859913 100644 --- a/tests/canonical_storage/test_generation_run_claims.py +++ b/tests/canonical_storage/test_generation_run_claims.py @@ -75,7 +75,7 @@ async def _manually_fail_expired_run( run_id, update=GenerationRunStatusUpdate( status=GenerationRunStatus.FAILED, - current_node="failed", + current_node=None, ended_at=now, error_message="Generation lease expired; failed manually.", error_category="launcher.lease_expired", diff --git a/tests/canonical_storage/test_generation_run_terminal_claims.py b/tests/canonical_storage/test_generation_run_terminal_claims.py index f16782ce..7a59f1c4 100644 --- a/tests/canonical_storage/test_generation_run_terminal_claims.py +++ b/tests/canonical_storage/test_generation_run_terminal_claims.py @@ -8,6 +8,7 @@ from episodic.canonical.domain import GenerationRunStatus from episodic.canonical.generation_run_errors import RunAlreadyTerminal +from episodic.canonical.generation_run_ports import GenerationRunStatusUpdate from episodic.canonical.storage import SqlAlchemyUnitOfWork from tests.canonical_storage._generation_run_support import ( NOW, @@ -33,7 +34,7 @@ async def test_sql_generation_run_claim_rejects_terminal_status( status: GenerationRunStatus, ) -> None: """The SQL adapter raises rather than claiming any terminal run.""" - run = dc.replace(make_generation_run(), status=status) + run = dc.replace(make_generation_run(), status=status, ended_at=NOW) await persist_generation_run_prerequisites(session_factory, run) async with SqlAlchemyUnitOfWork(session_factory) as uow: await uow.generation_runs.create_run(run) @@ -47,3 +48,39 @@ async def test_sql_generation_run_claim_rejects_terminal_status( started_at=NOW, lease_expires_at=NOW + dt.timedelta(minutes=5), ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("current_node", "ended_at", "message"), + [ + ("complete", NOW, "terminal generation runs must not have a current node"), + (None, None, "terminal generation runs must have an end time"), + ], +) +async def test_sql_generation_run_rejects_invalid_terminal_lifecycle( + session_factory: async_sessionmaker[AsyncSession], + current_node: str | None, + ended_at: dt.datetime | None, + message: str, +) -> None: + """The SQL adapter validates terminal lifecycle fields before flushing.""" + run = make_generation_run() + await persist_generation_run_prerequisites(session_factory, run) + + async with SqlAlchemyUnitOfWork(session_factory) as uow: + await uow.generation_runs.create_run(run) + with pytest.raises(ValueError, match=message): + await uow.generation_runs.update_run_status( + run.id, + update=GenerationRunStatusUpdate( + status=GenerationRunStatus.SUCCEEDED, + current_node=current_node, + ended_at=ended_at, + ), + ) + await uow.commit() + + async with SqlAlchemyUnitOfWork(session_factory) as uow: + stored = await uow.generation_runs.get_run(run.id) + assert stored == run, "Invalid terminal update must not mutate the persisted run." diff --git a/tests/canonical_storage/test_sql_generation_run_property_contract.py b/tests/canonical_storage/test_sql_generation_run_property_contract.py index 1c885e0d..842b129b 100644 --- a/tests/canonical_storage/test_sql_generation_run_property_contract.py +++ b/tests/canonical_storage/test_sql_generation_run_property_contract.py @@ -80,7 +80,7 @@ async def _recover_expired_lease( run_id, update=GenerationRunStatusUpdate( status=GenerationRunStatus.FAILED, - current_node="failed", + current_node=None, ended_at=NOW, error_message="Generation lease expired; failed manually.", error_category="launcher.lease_expired", diff --git a/tests/test_canonical_episode_factory.py b/tests/test_canonical_episode_factory.py new file mode 100644 index 00000000..6dfada1d --- /dev/null +++ b/tests/test_canonical_episode_factory.py @@ -0,0 +1,43 @@ +"""Unit tests for canonical draft-episode construction.""" + +import datetime as dt +import uuid + +from episodic.canonical.domain import ApprovalState, EpisodeStatus, TeiHeader +from episodic.canonical.episode_factory import build_draft_episode + + +def test_build_draft_episode_uses_header_content_and_draft_states() -> None: + """A new episode inherits header content with the initial lifecycle states.""" + now = dt.datetime(2026, 8, 23, 12, 0, tzinfo=dt.UTC) + header = TeiHeader( + id=uuid.uuid7(), + title="Episode title", + payload={}, + raw_xml="", + created_at=now, + updated_at=now, + ) + series_profile_id = uuid.uuid7() + episode_id = uuid.uuid7() + + episode = build_draft_episode( + episode_id=episode_id, + series_profile_id=series_profile_id, + header=header, + now=now, + ) + + assert episode.id == episode_id, "The reserved identifier must be retained." + assert episode.series_profile_id == series_profile_id, ( + "The supplied profile must own the new episode." + ) + assert episode.tei_header_id == header.id, "The parsed header must be retained." + assert episode.title == header.title, "The title must come from the header." + assert episode.tei_xml == header.raw_xml, "The TEI XML must come from the header." + assert episode.status is EpisodeStatus.DRAFT, "New episodes must begin as drafts." + assert episode.approval_state is ApprovalState.DRAFT, ( + "New episodes must begin with draft approval." + ) + assert episode.created_at == now, "The supplied timestamp must set creation." + assert episode.updated_at == now, "The supplied timestamp must set updates." diff --git a/tests/test_duplication_benchmark.py b/tests/test_duplication_benchmark.py new file mode 100644 index 00000000..b6ca61e0 --- /dev/null +++ b/tests/test_duplication_benchmark.py @@ -0,0 +1,377 @@ +"""Test the duplication benchmark parser and scorer contracts. + +Parser contract tests verify report-shape validation, path normalization, +and finding order for the pyscn and PyChase result schemas. Scorer contract +tests verify lane attribution, pair deduplication, overlap matching, and +expectation classification so benchmark results remain comparable across +detector runs. +""" + +import typing as typ + +import pytest + +from benchmarks.duplication.score import ( + Expectation, + Fragment, + Lane, + PairFinding, + parse_pychase_pairs, + parse_pyscn_pairs, + score_findings, +) + +if typ.TYPE_CHECKING: + from pathlib import Path + + +def _fragment(path: str = "a.py", start: int = 1, end: int = 20) -> Fragment: + """Return one test fragment spanning the supplied source lines.""" + return Fragment(path=path, start_line=start, end_line=end) + + +def _expectation( + identifier: str = "pair", + *, + lane: Lane = Lane.SYNTACTIC_CLONE, + is_clone: bool = True, + members: tuple[Fragment, Fragment] | None = None, +) -> Expectation: + """Return one labelled test expectation with two fragments.""" + first, second = members or (_fragment("a.py", 1, 20), _fragment("b.py", 1, 20)) + return Expectation( + identifier=identifier, + lane=lane, + is_clone=is_clone, + first=first, + second=second, + ) + + +def _finding( + first: Fragment | None = None, + second: Fragment | None = None, + *, + lane: Lane = Lane.SYNTACTIC_CLONE, +) -> PairFinding: + """Return one normalized test detector finding.""" + return PairFinding( + first=first or _fragment("a.py", 1, 20), + second=second or _fragment("b.py", 1, 20), + lane=lane, + category="candidate", + similarity=1.0, + ) + + +class TestParsePyscnPairs: + """pyscn clone-pair report parsing.""" + + def test_parses_locations_types_and_similarity(self, tmp_path: Path) -> None: + """Members, clone types, and similarity survive normalization.""" + payload = { + "clone": { + "clone_pairs": [ + { + "type": 4, + "similarity": 0.75, + "clone1": { + "location": { + "file_path": "pkg/a.py", + "start_line": 4, + "end_line": 12, + } + }, + "clone2": { + "location": { + "file_path": str(tmp_path / "pkg" / "b.py"), + "start_line": 15, + "end_line": 23, + } + }, + } + ] + } + } + findings = parse_pyscn_pairs(payload, corpus_root=tmp_path) + assert findings[0].first == _fragment("pkg/a.py", 4, 12), "first member" + assert findings[0].second == _fragment("pkg/b.py", 15, 23), "second member" + assert findings[0].lane is Lane.SEMANTIC_CLONE, "type 4 lane" + assert findings[0].category == "type-4", "category label" + assert findings[0].similarity == 0.75, "similarity value" + + def test_null_pair_array_is_empty_report(self, tmp_path: Path) -> None: + """Null pair arrays parse as empty pyscn reports.""" + payload = {"clone": {"clone_pairs": None}} + assert parse_pyscn_pairs(payload, corpus_root=tmp_path) == (), ( + "null clone_pairs must parse as an empty report" + ) + + def test_syntactic_lane_for_types_one_to_three(self, tmp_path: Path) -> None: + """Types 1-3 normalize into the syntactic lane.""" + payload = { + "clone": { + "clone_pairs": [ + { + "type": clone_type, + "similarity": 0.9, + "clone1": { + "location": { + "file_path": "a.py", + "start_line": 1, + "end_line": 9, + } + }, + "clone2": { + "location": { + "file_path": "b.py", + "start_line": 1, + "end_line": 9, + } + }, + } + for clone_type in (1, 2, 3) + ] + } + } + findings = parse_pyscn_pairs(payload, corpus_root=tmp_path) + assert all(f.lane is Lane.SYNTACTIC_CLONE for f in findings), ( + "types 1-3 must use the syntactic lane" + ) + + @pytest.mark.parametrize( + ("payload", "expected_error"), + [ + ([], TypeError), + ({"clone": []}, TypeError), + ({"clone": {"clone_pairs": [{"type": "x"}]}}, TypeError), + ( + { + "clone": { + "clone_pairs": [ + { + "type": 1, + "similarity": 1.5, + "clone1": { + "location": { + "file_path": "a.py", + "start_line": 1, + "end_line": 2, + } + }, + "clone2": { + "location": { + "file_path": "b.py", + "start_line": 1, + "end_line": 2, + } + }, + } + ] + } + }, + ValueError, + ), + ], + ids=["root-not-object", "clone-not-object", "type-not-int", "similarity-range"], + ) + def test_rejects_malformed_reports( + self, tmp_path: Path, payload: object, expected_error: type[Exception] + ) -> None: + """Shape violations raise instead of silently dropping findings.""" + with pytest.raises(expected_error): + parse_pyscn_pairs(payload, corpus_root=tmp_path) + + def test_rejects_paths_outside_corpus_root(self, tmp_path: Path) -> None: + """Absolute paths outside the corpus root are configuration errors.""" + payload = { + "clone": { + "clone_pairs": [ + { + "type": 1, + "similarity": 1.0, + "clone1": { + "location": { + "file_path": "/somewhere/else.py", + "start_line": 1, + "end_line": 2, + } + }, + "clone2": { + "location": { + "file_path": "b.py", + "start_line": 1, + "end_line": 2, + } + }, + } + ] + } + } + with pytest.raises(ValueError, match="outside corpus root"): + parse_pyscn_pairs(payload, corpus_root=tmp_path) + + +class TestParsePychasePairs: + """PyChase candidate report parsing.""" + + def test_parses_candidates_in_report_order(self, tmp_path: Path) -> None: + """Candidates normalize into syntactic-lane findings.""" + payload = { + "candidates": [ + { + "score": 0.925, + "left": { + "file": "pkg/a.py", + "start_line": 4, + "end_line": 12, + "qualname": "alpha", + }, + "right": { + "file": "pkg/b.py", + "start_line": 15, + "end_line": 23, + "qualname": "beta", + }, + } + ] + } + findings = parse_pychase_pairs(payload, corpus_root=tmp_path) + assert findings[0].first == _fragment("pkg/a.py", 4, 12), "left member" + assert findings[0].second == _fragment("pkg/b.py", 15, 23), "right member" + assert findings[0].lane is Lane.SYNTACTIC_CLONE, "candidate lane" + assert findings[0].similarity == 0.925, "candidate score" + + @pytest.mark.parametrize( + ("payload", "expected_error"), + [ + ([], TypeError), + ({"candidates": [{"score": "high"}]}, TypeError), + ( + { + "candidates": [ + { + "score": 1.0, + "left": {"file": "a.py", "start_line": 0, "end_line": 2}, + "right": {"file": "b.py", "start_line": 1, "end_line": 2}, + } + ] + }, + ValueError, + ), + ], + ids=["root-not-object", "score-not-number", "line-not-positive"], + ) + def test_rejects_malformed_reports( + self, tmp_path: Path, payload: object, expected_error: type[Exception] + ) -> None: + """Shape violations raise instead of silently dropping findings.""" + with pytest.raises(expected_error): + parse_pychase_pairs(payload, corpus_root=tmp_path) + + +class TestScoreFindings: + """Scoring semantics over labelled pairs.""" + + def test_clone_pair_reported_is_true_positive(self) -> None: + """Reported clone labels count as lane true positives.""" + scores = score_findings([_expectation()], [_finding()]) + assert scores[Lane.SYNTACTIC_CLONE].true_positives == 1, "one true positive" + assert scores[Lane.SYNTACTIC_CLONE].unmatched_findings == 0, "no unmatched" + + def test_control_pair_reported_is_false_positive(self) -> None: + """Reported non-clone labels count as lane false positives.""" + scores = score_findings( + [_expectation(is_clone=False)], + [_finding()], + ) + assert scores[Lane.SYNTACTIC_CLONE].false_positives == 1, "one false positive" + + def test_unreported_labels_split_by_liveness(self) -> None: + """Unreported labels are false negatives or true negatives.""" + scores = score_findings( + [ + _expectation("clone", is_clone=True), + _expectation( + "control", + is_clone=False, + members=(_fragment("c.py", 1, 20), _fragment("d.py", 1, 20)), + ), + ], + [], + ) + assert scores[Lane.SYNTACTIC_CLONE].false_negatives == 1, "missed clone" + assert scores[Lane.SYNTACTIC_CLONE].true_negatives == 1, "quiet control" + + def test_swapped_member_order_still_matches(self) -> None: + """Finding member order does not affect matching.""" + finding = _finding( + first=_fragment("b.py", 5, 15), + second=_fragment("a.py", 5, 15), + ) + scores = score_findings([_expectation()], [finding]) + assert scores[Lane.SYNTACTIC_CLONE].true_positives == 1, "swapped order match" + + def test_duplicate_pairs_count_once(self) -> None: + """Identical reported pairs are deduplicated before scoring.""" + scores = score_findings( + [_expectation()], + [_finding(), _finding()], + ) + assert scores[Lane.SYNTACTIC_CLONE].true_positives == 1, "deduplicated pair" + + def test_second_overlapping_pair_does_not_double_count(self) -> None: + """A second distinct pair matching the same label is ignored.""" + nested = _finding( + first=_fragment("a.py", 3, 18), + second=_fragment("b.py", 3, 18), + ) + scores = score_findings([_expectation()], [_finding(), nested]) + assert scores[Lane.SYNTACTIC_CLONE].true_positives == 1, "single credit" + assert scores[Lane.SYNTACTIC_CLONE].unmatched_findings == 0, "no unmatched" + + def test_unmatched_findings_use_finding_lane(self) -> None: + """Pairs without labels count against the reporting lane.""" + stray = _finding( + first=_fragment("x.py", 1, 9), + second=_fragment("y.py", 1, 9), + lane=Lane.SEMANTIC_CLONE, + ) + scores = score_findings([_expectation()], [stray]) + assert scores[Lane.SEMANTIC_CLONE].unmatched_findings == 1, "stray in own lane" + assert scores[Lane.SYNTACTIC_CLONE].false_negatives == 1, "label unmet" + + def test_matched_findings_use_expectation_lane(self) -> None: + """Matched pairs score in the label's lane, not the finding's.""" + semantic_label = _expectation("semantic", lane=Lane.SEMANTIC_CLONE) + scores = score_findings([semantic_label], [_finding()]) + assert scores[Lane.SEMANTIC_CLONE].true_positives == 1, "label lane credited" + assert scores[Lane.SYNTACTIC_CLONE].true_positives == 0, "finding lane not" + + def test_rejects_duplicate_expectation_identifiers(self) -> None: + """Ambiguous labels are configuration errors.""" + with pytest.raises(ValueError, match="duplicate expectation"): + score_findings( + [ + _expectation("same"), + _expectation( + "same", + members=(_fragment("c.py", 1, 5), _fragment("d.py", 1, 5)), + ), + ], + [], + ) + + def test_rejects_duplicate_expectation_pairs(self) -> None: + """Two labels naming the same unordered pair are rejected.""" + with pytest.raises(ValueError, match="duplicate expectation"): + score_findings( + [ + _expectation("one"), + _expectation( + "two", + members=(_fragment("b.py", 1, 20), _fragment("a.py", 1, 20)), + ), + ], + [], + ) diff --git a/tests/test_duplication_benchmark_oracle.py b/tests/test_duplication_benchmark_oracle.py new file mode 100644 index 00000000..0fe95276 --- /dev/null +++ b/tests/test_duplication_benchmark_oracle.py @@ -0,0 +1,44 @@ +"""Integrity checks for the checked-in duplication benchmark oracle.""" + +import json +from pathlib import Path + +from benchmarks.duplication.score import Expectation, Fragment, Lane, score_findings + +REPO_ROOT = Path(__file__).resolve().parents[1] +BENCHMARK_ROOT = REPO_ROOT / "benchmarks" / "duplication" + + +def test_expectations_load_and_reference_real_units() -> None: + """Every labelled unit names an existing corpus source span.""" + raw = json.loads((BENCHMARK_ROOT / "expectations.json").read_text(encoding="utf-8")) + expectations = [ + Expectation( + identifier=entry["identifier"], + lane=Lane(entry["lane"]), + is_clone=entry["is_clone"], + first=Fragment( + path=entry["first"]["path"], + start_line=entry["first"]["start_line"], + end_line=entry["first"]["end_line"], + ), + second=Fragment( + path=entry["second"]["path"], + start_line=entry["second"]["start_line"], + end_line=entry["second"]["end_line"], + ), + ) + for entry in raw + ] + score_findings(expectations, []) + for expectation in expectations: + for member in (expectation.first, expectation.second): + source = BENCHMARK_ROOT / "corpus" / member.path + assert member.start_line >= 1, f"{member.path} span must start at one" + assert member.start_line <= member.end_line, ( + f"{member.path} span must not be inverted" + ) + line_count = len(source.read_text(encoding="utf-8").splitlines()) + assert member.end_line <= line_count, ( + f"{member.path} span exceeds file length" + ) diff --git a/tests/test_duplication_benchmark_properties.py b/tests/test_duplication_benchmark_properties.py new file mode 100644 index 00000000..7f93591e --- /dev/null +++ b/tests/test_duplication_benchmark_properties.py @@ -0,0 +1,132 @@ +"""Boundary and property checks for the duplication benchmark.""" + +import typing as typ + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from benchmarks.duplication.corpus.controls import parse_ratio +from benchmarks.duplication.corpus.pricing import recent_error_messages +from benchmarks.duplication.corpus.reporting import latest_alert_titles +from benchmarks.duplication.models import Expectation, Fragment, Lane, PairFinding +from benchmarks.duplication.score import score_findings + + +@pytest.mark.parametrize("ratio", ["0%", "3:4", "0", "1.0"]) +def test_parse_ratio_returns_non_negative_fractions(ratio: str) -> None: + """Accepted ratio forms resolve to non-negative fractions.""" + assert parse_ratio(ratio) >= 0, f"Expected {ratio!r} to be non-negative." + + +@pytest.mark.parametrize("ratio", ["-20%", "-1:2", "-0.5"]) +def test_parse_ratio_rejects_negative_fractions(ratio: str) -> None: + """Percentage, colon, and bare forms reject negative fractions.""" + with pytest.raises(ValueError, match="ratio must not be negative"): + parse_ratio(ratio) + + +@pytest.mark.parametrize("ratio", ["inf", "-inf"]) +def test_parse_ratio_rejects_non_finite_fractions(ratio: str) -> None: + """Infinite ratio forms cannot enter the labelled benchmark corpus.""" + with pytest.raises(ValueError, match="ratio must be finite"): + parse_ratio(ratio) + + +@pytest.mark.parametrize("limit", [0, -1]) +def test_message_collectors_reject_non_positive_limits(limit: int) -> None: + """Aligned corpus collectors return no messages for non-positive limits.""" + events: list[dict[str, object]] = [{"level": "error", "message": "failed"}] + assert not recent_error_messages(events, limit), ( + "Pricing collector must reject non-positive limits." + ) + assert not latest_alert_titles(events, limit), ( + "Reporting collector must reject non-positive limits." + ) + + +@given( + events=st.lists( + st.fixed_dictionaries({ + "level": st.sampled_from(("error", "info", "warning")), + "message": st.text(max_size=20), + }), + max_size=30, + ), + limit=st.integers(min_value=-5, max_value=35), +) +def test_message_collectors_preserve_error_order_and_limit( + events: list[dict[str, str]], + limit: int, +) -> None: + """Aligned collectors return the same bounded ordered error messages.""" + expected = [ + event["message"] + for event in events + if event["level"] == "error" and event["message"] + ][: max(limit, 0)] + object_events = typ.cast("list[dict[str, object]]", events) + assert recent_error_messages(object_events, limit) == expected, ( + "Pricing collector must retain the requested ordered error messages." + ) + assert latest_alert_titles(object_events, limit) == expected, ( + "Reporting collector must retain the requested ordered error messages." + ) + + +@given( + first_offset=st.integers(min_value=-5, max_value=5), + second_offset=st.integers(min_value=-5, max_value=5), + member_order=st.integers(min_value=0, max_value=1), + repeat_count=st.integers(min_value=1, max_value=5), +) +def test_score_deduplicates_overlapping_unordered_findings( + first_offset: int, + second_offset: int, + member_order: int, + repeat_count: int, +) -> None: + """Scoring credits one overlapping clone regardless of report order.""" + first = Fragment(path="first.py", start_line=20, end_line=40) + second = Fragment(path="second.py", start_line=20, end_line=40) + expectation = Expectation( + identifier="clone", + lane=Lane.SYNTACTIC_CLONE, + is_clone=True, + first=first, + second=second, + ) + finding_members = ( + Fragment( + path="first.py", + start_line=20 + first_offset, + end_line=40 + first_offset, + ), + Fragment( + path="second.py", + start_line=20 + second_offset, + end_line=40 + second_offset, + ), + ) + reported_first, reported_second = ( + reversed(finding_members) if member_order else finding_members + ) + finding = PairFinding( + first=reported_first, + second=reported_second, + lane=Lane.SYNTACTIC_CLONE, + category="candidate", + similarity=1.0, + ) + + score = score_findings([expectation], [finding] * repeat_count)[ + Lane.SYNTACTIC_CLONE + ] + + assert score.true_positives == 1, "One label must receive one clone credit." + assert score.false_positives == 0, "A labelled clone must not be a false positive." + assert score.false_negatives == 0, ( + "A matched clone must not remain a false negative." + ) + assert score.true_negatives == 0, "The generated input has no non-clone label." + assert score.unmatched_findings == 0, "Overlapping reports must match the label." diff --git a/tests/test_env_runtime_wiring.py b/tests/test_env_runtime_wiring.py index 01465127..176a80fb 100644 --- a/tests/test_env_runtime_wiring.py +++ b/tests/test_env_runtime_wiring.py @@ -265,7 +265,6 @@ async def test_create_app_from_env_wires_database_readiness_probe( @pytest.mark.asyncio async def test_create_app_from_env_runs_shutdown_hooks_during_lifespan( - migrated_database_url: str, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -274,7 +273,7 @@ async def test_create_app_from_env_runs_shutdown_hooks_during_lifespan( from episodic.api import runtime as runtime_module - monkeypatch.setenv("DATABASE_URL", migrated_database_url) + monkeypatch.setenv("DATABASE_URL", "postgresql://example.test/episodic") monkeypatch.setenv("SOURCE_INTAKE_OBJECT_STORE_ROOT", str(tmp_path / "objects")) shutdown_hook_called = False diff --git a/tests/test_generation_run_launcher.py b/tests/test_generation_run_launcher.py index 6343f94a..1c1319f6 100644 --- a/tests/test_generation_run_launcher.py +++ b/tests/test_generation_run_launcher.py @@ -144,9 +144,7 @@ async def test_launcher_completes_run_and_records_cost( assert run.status is GenerationRunStatus.SUCCEEDED, ( f"run {run_id} status={run.status}; events={events!r}" ) - assert run.current_node == "complete", ( - f"run {run_id} current_node={run.current_node!r}" - ) + assert run.current_node is None, f"run {run_id} current_node={run.current_node!r}" assert episode is not None, f"episode {episode_id} was not persisted" assert episode.tei_xml == valid_tei(), ( f"episode {episode_id} TEI={episode.tei_xml!r}" @@ -206,9 +204,7 @@ async def test_launcher_records_provider_failure( assert run.error_message == "try again later", ( f"run {run_id} error={run.error_message!r}" ) - assert run.current_node == "failed", ( - f"run {run_id} current_node={run.current_node!r}" - ) + assert run.current_node is None, f"run {run_id} current_node={run.current_node!r}" assert [event.kind for event in events] == ["run.started", "run.failed"], ( f"run {run_id} events={events!r}" ) diff --git a/tests/test_generation_run_lifecycle.py b/tests/test_generation_run_lifecycle.py new file mode 100644 index 00000000..228b7d67 --- /dev/null +++ b/tests/test_generation_run_lifecycle.py @@ -0,0 +1,56 @@ +"""Tests for terminal generation-run lifecycle invariants.""" + +import dataclasses as dc +import datetime as dt +import uuid + +import pytest + +from episodic.canonical.domain import GenerationRun, GenerationRunStatus +from episodic.canonical.generation_quality import QaStatus, QualityMode + +NOW = dt.datetime(2026, 6, 4, 8, 0, tzinfo=dt.UTC) + + +def _pending_run() -> GenerationRun: + """Build an otherwise valid pending generation run.""" + return GenerationRun( + id=uuid.uuid7(), + episode_id=uuid.uuid7(), + source_bundle_id=uuid.uuid7(), + actor="editor@example.com", + status=GenerationRunStatus.PENDING, + current_node=None, + budget_snapshot={"limit": 10}, + configuration={"model": "gpt-4.1"}, + created_at=NOW, + updated_at=NOW, + started_at=None, + ended_at=None, + error_message=None, + quality_mode=QualityMode.DRAFT_WITHOUT_QA, + qa_status=QaStatus.SKIPPED, + skip_qa_rationale="No-QA vertical-slice draft.", + ) + + +@pytest.mark.parametrize( + ("current_node", "ended_at", "message"), + [ + ("complete", NOW, "terminal generation runs must not have a current node"), + (None, None, "terminal generation runs must have an end time"), + ], +) +def test_generation_run_rejects_invalid_terminal_lifecycle( + current_node: str | None, + ended_at: dt.datetime | None, + message: str, +) -> None: + """Terminal runs require an end time and clear their active node.""" + with pytest.raises(ValueError, match=message): + dc.replace( + _pending_run(), + status=GenerationRunStatus.SUCCEEDED, + current_node=current_node, + ended_at=ended_at, + ) diff --git a/tests/test_generation_run_paging.py b/tests/test_generation_run_paging.py new file mode 100644 index 00000000..e1dd2b34 --- /dev/null +++ b/tests/test_generation_run_paging.py @@ -0,0 +1,53 @@ +"""Unit tests for generation-run event-page validation.""" + +import pytest + +from episodic.canonical.generation_run_ports import ( + EventSeq, + event_page_minimum_sequence, + event_seq, +) + + +@pytest.mark.parametrize( + ("after_seq", "limit", "offset", "expected"), + [(None, 10, 0, 0), (event_seq(4), 10, 0, 4), (None, 10, 3, 0)], +) +def test_event_page_minimum_sequence_returns_cursor_boundary( + after_seq: EventSeq | None, + limit: int, + offset: int, + expected: int, +) -> None: + """Valid pagination inputs produce their exclusive event-sequence bound.""" + assert ( + event_page_minimum_sequence( + after_seq=after_seq, + limit=limit, + offset=offset, + ) + == expected + ), f"Expected boundary {expected} for cursor {after_seq!r}." + + +@pytest.mark.parametrize( + ("after_seq", "limit", "offset", "message"), + [ + (None, -1, 0, "limit and offset must be non-negative"), + (None, 1, -1, "limit and offset must be non-negative"), + (event_seq(1), 1, 1, "after_seq and offset cannot be combined"), + ], +) +def test_event_page_minimum_sequence_rejects_invalid_pagination( + after_seq: EventSeq | None, + limit: int, + offset: int, + message: str, +) -> None: + """Invalid pagination combinations retain the port contract errors.""" + with pytest.raises(ValueError, match=message): + event_page_minimum_sequence( + after_seq=after_seq, + limit=limit, + offset=offset, + ) diff --git a/tests/test_generation_run_port_contract.py b/tests/test_generation_run_port_contract.py index ac787e76..e79ded0b 100644 --- a/tests/test_generation_run_port_contract.py +++ b/tests/test_generation_run_port_contract.py @@ -26,6 +26,7 @@ GenerationEventLog, GenerationRunPort, GenerationRunRepository, + GenerationRunStatusUpdate, event_seq, ) from tests.test_generation_run_port_contract_support import NoopGenerationRunPort @@ -263,7 +264,9 @@ async def test_claim_run_for_execution_rejects_terminal_runs( status: GenerationRunStatus, ) -> None: """Terminal runs cannot be reclaimed for execution.""" - run = await store.create_run(dc.replace(make_generation_run(), status=status)) + run = await store.create_run( + dc.replace(make_generation_run(), status=status, ended_at=NOW) + ) with pytest.raises(RunAlreadyTerminal, match="generation run is already"): await store.claim_run_for_execution( @@ -273,6 +276,37 @@ async def test_claim_run_for_execution_rejects_terminal_runs( lease_expires_at=NOW + dt.timedelta(minutes=5), ) + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("current_node", "ended_at", "message"), + [ + ("complete", NOW, "terminal generation runs must not have a current node"), + (None, None, "terminal generation runs must have an end time"), + ], + ) + async def test_update_run_status_rejects_invalid_terminal_lifecycle( + self, + store: InMemoryGenerationRunStore, + current_node: str | None, + ended_at: dt.datetime | None, + message: str, + ) -> None: + """The in-memory adapter rejects terminal lifecycle invariant breaks.""" + run = await store.create_run(make_generation_run()) + + with pytest.raises(ValueError, match=message): + await store.update_run_status( + run.id, + update=GenerationRunStatusUpdate( + status=GenerationRunStatus.SUCCEEDED, + current_node=current_node, + ended_at=ended_at, + ), + ) + assert await store.get_run(run.id) == run, ( + "Invalid terminal update must not persist." + ) + class TestGenerationEventLog: """Contract tests for generation event-log operations.""" diff --git a/tests/test_logging.py b/tests/test_logging.py index 679c9d40..41023569 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -93,7 +93,9 @@ class _LogOnlySpyLogger: def __init__(self) -> None: """Initialise an empty call record.""" - self.calls: list[tuple[int, str, object | None, bool]] = [] + self.calls: list[ + tuple[int | episodic_logging.LogLevel, str, object | None, bool] + ] = [] # pylint: disable-next=too-many-arguments # mirrors stdlib/femtologging call signature def log( @@ -110,6 +112,28 @@ def log( self.calls.append((level, message, exc_info, stack_info)) +class _RaisingInfoLogger(_LogOnlySpyLogger): + """Record a convenience call before simulating an emission failure.""" + + def info( + self, + message: str, + /, + *, + exc_info: object | None = None, + stack_info: bool = False, + ) -> None: + """Record one call then raise the original emission failure.""" + self.calls.append(( + episodic_logging.LogLevel.INFO, + message, + exc_info, + stack_info, + )) + msg = "emission failed" + raise TypeError(msg) + + class _CollectorHandler: """Capture femtologging records through the Python handler protocol.""" @@ -277,6 +301,18 @@ def test_log_wrappers_fall_back_to_logger_log_when_needed( assert logger.calls == snapshot, "fallback logger calls must match the snapshot" +def test_log_wrapper_does_not_retry_a_failed_convenience_emission() -> None: + """A convenience-method TypeError propagates without a second log call.""" + logger = _RaisingInfoLogger() + + with pytest.raises(TypeError, match="emission failed"): + episodic_logging.log_info(logger, "Loaded %s documents", 3) + + assert logger.calls == [ + (episodic_logging.LogLevel.INFO, "Loaded 3 documents", None, False) + ], "A failed convenience call must not be retried through log()." + + def test_femtologging_exposes_stdlib_style_logger_surface() -> None: """The upgraded dependency should expose stdlib-like logger helpers.""" import femtologging diff --git a/tests/test_skylos_lint_contract.py b/tests/test_skylos_lint_contract.py index 9d3a9513..cdf8e937 100644 --- a/tests/test_skylos_lint_contract.py +++ b/tests/test_skylos_lint_contract.py @@ -125,17 +125,17 @@ def test_make_lint_runs_local_blocking_dead_code_scan() -> None: ), "Expected the blocking Skylos command to retain its gate flags." -def test_skylos_allow_requires_name_and_reason() -> None: - """Guard the command that adds a named, non-entry-point exception.""" +def test_skylos_allow_requires_symbol_and_reason() -> None: + """Guard the command that adds a symbol-specific, non-entry-point exception.""" makefile = (REPOSITORY_ROOT / "Makefile").read_text(encoding="utf-8") required_fragments = ( "skylos-allow: ## Document one named Skylos exception, not an entry point", - "skylos-allow: export SKYLOS_NAME = $(value NAME)", - "skylos-allow: export SKYLOS_REASON = $(value REASON)", - 'test -n "$${SKYLOS_NAME}"', + "skylos-allow: export SKYLOS_SYMBOL = $(call cli_value,SYMBOL)", + "skylos-allow: export SKYLOS_REASON = $(call cli_value,REASON)", + 'test -n "$${SKYLOS_SYMBOL}"', 'test -n "$${SKYLOS_REASON}"', - "NAME is required for a named whitelist exception", + "SYMBOL is required for a named whitelist exception", "REASON is required for a named whitelist exception", ) missing_fragments = tuple( @@ -144,7 +144,9 @@ def test_skylos_allow_requires_name_and_reason() -> None: assert not missing_fragments, ( f"Expected skylos-allow target requirements; missing {missing_fragments!r}." ) - command = '$(SKYLOS) whitelist "$${SKYLOS_NAME}" --reason "$${SKYLOS_REASON}"' + # The whitelist subcommand must run without the --config-file prefix that + # $(SKYLOS) carries; global options stop Skylos dispatching the subcommand. + command = '$(SKYLOS_CLI) whitelist "$${SKYLOS_SYMBOL}" --reason "$${SKYLOS_REASON}"' assert makefile.count(command) == 1, ( "Expected exactly one safely quoted Skylos whitelist command." ) @@ -160,7 +162,7 @@ def test_skylos_allow_preserves_metacharacters_as_arguments(tmp_path: Path) -> N encoding="utf-8", ) recorder.chmod(0o755) - name = f'registered"; touch {marker}; printf "' + symbol = f'registered"; touch {marker}; printf "' reason = f"loaded by `touch {marker}` and $(touch {marker})" make_executable = shutil.which("make") assert make_executable is not None, "Expected make to be available for the test." @@ -170,9 +172,9 @@ def test_skylos_allow_preserves_metacharacters_as_arguments(tmp_path: Path) -> N make_executable, "--no-print-directory", "skylos-allow", - f"NAME={name}", + f"SYMBOL={symbol}", f"REASON={reason}", - f"SKYLOS={recorder}", + f"SKYLOS_CLI={recorder}", ], cwd=REPOSITORY_ROOT, env={**os.environ, "SKYLOS_CAPTURE": str(capture)}, @@ -184,10 +186,10 @@ def test_skylos_allow_preserves_metacharacters_as_arguments(tmp_path: Path) -> N assert result.returncode == 0, "Expected quoted metacharacters to reach Skylos." assert capture.read_text(encoding="utf-8").splitlines() == [ "whitelist", - name, + symbol, "--reason", reason, - ], "Expected NAME and REASON to remain single whitelist arguments." + ], "Expected SYMBOL and REASON to remain single whitelist arguments." assert not marker.exists(), "Expected no injected shell command to execute." @@ -196,10 +198,10 @@ def test_skylos_allow_preserves_metacharacters_as_arguments(tmp_path: Path) -> N [ ( "REASON=loaded by the verified plugin registry", - "Error: NAME is required for a named whitelist exception", + "Error: SYMBOL is required for a named whitelist exception", ), ( - "NAME=registered_handler", + "SYMBOL=registered_handler", "Error: REASON is required for a named whitelist exception", ), ], @@ -226,7 +228,7 @@ def test_skylos_allow_rejects_missing_required_value( "--no-print-directory", "skylos-allow", provided_assignment, - f"SKYLOS={recorder}", + f"SKYLOS_CLI={recorder}", ], cwd=REPOSITORY_ROOT, env={**os.environ, "SKYLOS_CAPTURE": str(capture)}, @@ -240,6 +242,45 @@ def test_skylos_allow_rejects_missing_required_value( assert not capture.exists(), "Expected Skylos not to run after validation fails." +def test_skylos_allow_ignores_wsl_host_name(tmp_path: Path) -> None: + """An ambient WSL ``NAME`` value cannot become a Skylos exception.""" + recorder = tmp_path / "skylos-recorder" + capture = tmp_path / "arguments.txt" + recorder.write_text( + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$SKYLOS_CAPTURE"\n', + encoding="utf-8", + ) + recorder.chmod(0o755) + make_executable = shutil.which("make") + assert make_executable is not None, "Expected make to be available for the test." + + result = subprocess.run( # noqa: S603 - tests Makefile validation safely + [ + make_executable, + "--no-print-directory", + "skylos-allow", + "REASON=Loaded by the plugin registry", + f"SKYLOS_CLI={recorder}", + ], + cwd=REPOSITORY_ROOT, + env={ + **os.environ, + "NAME": "wsl-hostname", + "SYMBOL": "episodic.ambient.wsl_host_name", + "SKYLOS_CAPTURE": str(capture), + }, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 2, "An ambient NAME value must not satisfy SYMBOL." + assert "Error: SYMBOL is required" in result.stderr, ( + "Expected the missing-SYMBOL diagnostic." + ) + assert not capture.exists(), "Skylos must not run without a CLI SYMBOL." + + def test_skylos_cache_is_ignored() -> None: """Keep local grep-verification cache files out of version control.""" gitignore = (REPOSITORY_ROOT / ".gitignore").read_text(encoding="utf-8")