diff --git a/.docket/ledger.jsonl b/.docket/ledger.jsonl index 6cb1a5e..39dccc7 100644 --- a/.docket/ledger.jsonl +++ b/.docket/ledger.jsonl @@ -82,3 +82,5 @@ {"schema":2,"kind":"decision","id":"d82","text":"How should Docket's Python modules be organised?","state":"adopted","ts":"2026-09-13T19:51:29+00:00","author":"claude-code","session":"session_01N7aaCPBeeqQVGEqYgA1oQs","branch":"feat/docket-package","scope":["docket/**","bin/docket"],"rationale":"The user requires absolute imports throughout. One module carries one canonical name, which resolves identically from every entry point once the repo root is on sys.path. d79 said relative. The module count is six, not five: docket_update.py shipped in 0.11.0 after the spec was written.","supports":[],"depends_on":[],"answers":[],"supersedes":["d79"],"evidence":[],"revisit":"","cost_if_wrong":"Absolute imports depend on the repo root being importable. No conftest.py exists, so the test suite resolves the package only because unittest discover runs with the repo root as cwd.","pinned":false,"choice":"Convert lib/ into a contained docket package holding the six existing modules plus the CLI. bin/docket becomes a launcher that inserts the repo root on sys.path and calls docket.cli.main(). Every import is absolute, inside the package included: from docket.config import DEFAULTS, never from .config import DEFAULTS. The CLI splits into cli/{__init__,term,record,query,graph,admin}.py, with env.py for environment and ledger-location resolution.","alternatives":["Convert lib/ into a contained docket package holding the six existing modules plus the CLI. bin/docket becomes a launcher that inserts the repo root on sys.path and calls docket.cli.main(). Every import is absolute, inside the package included: from docket.config import DEFAULTS, never from .config import DEFAULTS. The CLI splits into cli/{__init__,term,record,query,graph,admin}.py, with env.py for environment and ledger-location resolution.","Relative imports inside the package, as d79 recorded","Leave the flat lib/ modules and keep the try/except ImportError and runtime importlib hacks","Package the tool with zipapp into a single docket.pyz"],"decided_by":"user"} {"schema":2,"kind":"claim","id":"c83","text":"OpenRouter enforces a JSON schema per endpoint, not per model, and some upstream providers silently fall back to json_object.","state":"accepted","ts":"2026-09-13T20:56:40+00:00","author":"claude-code","session":"session_01N7aaCPBeeqQVGEqYgA1oQs","branch":"feat/docket-package","scope":["docket/construct/**"],"rationale":"The same model reached through different upstream providers may or may not honour json_schema. OpenRouter's own guidance flags the silent fallback as a bug class. provider.require_parameters=true routes only to providers that support it.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"https://openrouter.ai/docs/guides/features/structured-outputs"}],"revisit":"","cost_if_wrong":"Trusting response_format without local validation would let a silently unstructured response through as a record.","pinned":false} {"schema":2,"kind":"decision","id":"d84","text":"Which LLM client does docket construct depend on?","state":"adopted","ts":"2026-09-13T20:56:51+00:00","author":"claude-code","session":"session_01N7aaCPBeeqQVGEqYgA1oQs","branch":"feat/docket-package","scope":["docket/construct/**"],"rationale":"litellm shipped a credential stealer on PyPI on 2026-03-24 as releases 1.82.7 and 1.82.8, delivered through a .pth file that executes at interpreter startup. A SessionStart hook runs docket every session, so that payload shape is the worst possible fit. litellm also pulls about 28MB including boto3, tiktoken and uvloop. OpenRouter collapses four providers into one endpoint and one auth scheme, which is what the openai SDK already serves; instructor, pydantic-ai and mirascope add agent-orchestration weight for a job with two flat call shapes. The openai SDK publishes through PyPI trusted publishing with Sigstore attestations and has no incident history.","supports":[["c83"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"The openai SDK requires pydantic, so construct cannot stay standard-library-only. A hand-rolled urllib client would avoid that at the price of owning retry, refusal and error-shape handling.","pinned":false,"choice":"The official openai SDK, pointed at OpenRouter's OpenAI-compatible endpoint, imported lazily inside the construct command body. Set provider.require_parameters=true so routing reaches only providers that honour json_schema, and validate every response locally regardless. Users with their own credentials get separate lightweight code paths per provider SDK, each lazily imported when selected.","alternatives":["The official openai SDK, pointed at OpenRouter's OpenAI-compatible endpoint, imported lazily inside the construct command body. Set provider.require_parameters=true so routing reaches only providers that honour json_schema, and validate every response locally regardless. Users with their own credentials get separate lightweight code paths per provider SDK, each lazily imported when selected.","litellm, a universal multi-provider gateway","instructor, pydantic-ai or mirascope layered over a client","A hand-rolled OpenAI-compatible client over urllib, about 50 lines"],"decided_by":"user"} +{"schema":2,"kind":"claim","id":"c85","text":"A GEMINI_API_KEY beginning AQ. authenticates against the Gemini API with the x-goog-api-key header, and against its OpenAI-compatible endpoint as a Bearer token.","state":"accepted","ts":"2026-09-13T22:48:20+00:00","author":"claude-code","session":"session_01N7aaCPBeeqQVGEqYgA1oQs","branch":"feat/docket-construct","scope":["docket/construct/client.py"],"rationale":"Verified against gemini-3.8-flash on 2026-09-14. The same key returns 401 as a Bearer token on the native v1beta endpoint, which is the failure the construct spec recorded as ACCESS_TOKEN_TYPE_UNSUPPORTED.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Treating the AQ. prefix as unusable would send someone to buy a second key they already hold.","pinned":false} +{"schema":2,"kind":"claim","id":"c86","text":"Batching the linker by document leaves supersession undetected, because same-topic documents written months apart land in different batches.","state":"accepted","ts":"2026-09-13T22:48:20+00:00","author":"claude-code","session":"session_01N7aaCPBeeqQVGEqYgA1oQs","branch":"feat/docket-construct","scope":["docket/construct/link.py"],"rationale":"Measured over 67 documents and 699 proposals: batching raised support edges from 22 to 32 across seven calls and left supersedes at 1. Supersession is the one relation that depends on two records being far apart in time.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Reading the low supersedes count as an absence of supersession in the corpus would hide a batching artefact.","pinned":false} diff --git a/docket/cli/__init__.py b/docket/cli/__init__.py index 770ec26..eafcce4 100644 --- a/docket/cli/__init__.py +++ b/docket/cli/__init__.py @@ -5,6 +5,7 @@ from docket import version from docket.ledger import KINDS, STATES, LedgerError +from docket.cli.construct import cmd_construct from docket.cli.record import cmd_claim, cmd_decision, cmd_question from docket.cli.query import CONTEXT_ENVELOPES, cmd_context, cmd_list, cmd_show, cmd_where from docket.cli.graph import cmd_graph @@ -154,6 +155,18 @@ def main(argv: list[str] | None = None) -> int: it = sub.add_parser("init", help="move this project's ledger into the repository") it.set_defaults(func=cmd_init) + cs = sub.add_parser("construct", help="stage ledger proposals from written history") + cs.add_argument("paths", nargs="*", help="documents or directories to read") + cs.add_argument("--review", action="store_true", + help="print staged proposals with their source anchors") + cs.add_argument("--accept", action="store_true", + help="append accepted proposals to the ledger") + cs.add_argument("--source", help="with --accept, take only this document's records") + cs.add_argument("--jobs", type=int, default=8, help="concurrent extraction calls") + cs.add_argument("--dry-run", action="store_true", + help="list the documents that would be read; call nothing") + cs.set_defaults(func=cmd_construct) + co = sub.add_parser("completion", help="print a shell completion script") co.add_argument("shell", choices=("bash", "zsh", "fish")) co.set_defaults(func=cmd_completion) diff --git a/docket/cli/construct.py b/docket/cli/construct.py new file mode 100644 index 0000000..73a7e1b --- /dev/null +++ b/docket/cli/construct.py @@ -0,0 +1,192 @@ +"""The construct subcommand: stage proposals, review them, accept them. + +Three steps, never one command. Extraction proposes, a human reads, acceptance +writes. Collapsing them would put records in the ledger nobody approved, and +approval is the whole thing the ledger records. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +import textwrap +from pathlib import Path + +from docket import env + +# docket.construct is imported inside the function bodies below, never here. A +# SessionStart hook builds this parser on every session, and it must not pay for +# a subpackage only this command uses. + +MISSING_SDK = ( + "docket construct needs the openai SDK:\n" + " uv pip install openai (or: python3 -m pip install --user openai)\n" + "It is the only command that does. Every other command, and the " + "SessionStart hook, run without it." +) + +_WIDTH = 88 +_TEXT_MAX = 400 + +# Every string in a proposal came from a model. Control characters would reach +# the terminal verbatim, and an escape sequence can repaint a reviewer's screen. +_CONTROL = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]") + + +def _safe(text: str, limit: int = _TEXT_MAX) -> str: + """Model text fit to print: no control characters, bounded length.""" + clean = _CONTROL.sub("", str(text)) + return clean if len(clean) <= limit else clean[:limit] + "..." + + +def _docket_dir() -> Path: + """The directory holding both the staging file and the ledger. + + A project's own .docket wins. Construct's main case is a project whose + ledger does not exist yet, and there ledger_path() answers with the global + store, which is nowhere the user would look for their own proposals. Pairing + the two matters: proposals in one place and the records they became in + another is worse than either choice alone. + """ + local = env.project_root() / ".docket" + if local.is_dir(): + return local + return env.ledger_path().parent + + +def _staged_path() -> Path: + return _docket_dir() / "proposed.jsonl" + + +def _ledger_path() -> Path: + return _docket_dir() / env.LEDGER.name + + +def _live_paths() -> set[str]: + """Every tracked path in the repository, for resolving a record's scope.""" + try: + done = subprocess.run(["git", "ls-files", "-z"], + capture_output=True, timeout=10) + except (OSError, subprocess.SubprocessError): + return set() + if done.returncode != 0: + return set() + text = done.stdout.decode("utf-8", errors="surrogateescape") + return {path for path in text.split("\0") if path} + + +def review(staged: Path, live: set[str]) -> int: + """Print staged proposals grouped by source, strongest first. + + Each one carries its anchor, which is verbatim source text, so a reader can + open the document and check the record against the line it came from. + """ + from docket.construct import stage + + try: + proposals = stage.read(staged) + except stage.StageError as exc: + print(f"docket: {exc}", file=sys.stderr) + return 1 + groups = stage.review_groups(proposals, live) + if not groups: + print("docket: nothing staged for review") + return 0 + + resolved, scoped = stage.resolution_rate( + [p for p in proposals if p.get("state") == "staged"], live) + print(f"# scope resolves: {resolved}/{scoped}") + print(f"# staged: {sum(len(items) for _, items in groups)}\n") + + for name, items in groups: + print(f"## {_safe(name)}") + for item in items: + mark = "" if stage.resolves(item, live) else " [unresolved scope]" + print(f" {item['kind']} ({item.get('confidence', 'low')}){mark}") + for line in textwrap.wrap(_safe(item["text"]), width=_WIDTH): + print(f" {line}") + if item.get("choice"): + for line in textwrap.wrap(f"choice: {_safe(item['choice'])}", + width=_WIDTH): + print(f" {line}") + if item.get("scope"): + print(f" scope: {_safe(', '.join(item['scope']))}") + print(f" anchor: {_safe(item['anchor'])}") + print(f" key: {item['key'][:12]}") + print() + return 0 + + +def accept_staged(staged: Path, ledger: Path, source: str | None = None) -> int: + """Append every accepted proposal through the ordinary ledger writer.""" + from docket.construct import accept + + written, skipped = accept.run(staged, ledger, source=source) + if not written and not skipped: + print("docket: nothing accepted yet; mark proposals accepted first") + return 0 + print(f"docket: wrote {written} record{'' if written == 1 else 's'}") + if skipped: + print(f"docket: skipped {skipped}; a record whose support was not " + "accepted would lose its grounds") + return 0 + + +def cmd_construct(args: argparse.Namespace) -> int: + staged = _staged_path() + + if args.review and args.accept: + print("docket: --review and --accept are separate steps", file=sys.stderr) + return 2 + if args.paths and (args.review or args.accept): + print("docket: extraction and review are separate steps; run construct " + "with paths first, then --review", file=sys.stderr) + return 2 + + if args.review: + return review(staged, _live_paths()) + if args.accept: + return accept_staged(staged, _ledger_path(), source=args.source) + if not args.paths: + print("docket: name the documents to read, or pass --review or --accept", + file=sys.stderr) + return 2 + + return _extract(args, staged) + + +def _extract(args: argparse.Namespace, staged: Path) -> int: + """Run both passes and stage the result. + + The SDK import lives here, inside the one command body that needs it. + """ + from docket.construct import run, stage + + # A dry run reads nothing and calls nothing, so it must not demand the + # dependency. It is the one command someone runs to see what would happen. + if not args.dry_run: + try: + import openai # noqa: F401 + except ImportError: + print(MISSING_SDK, file=sys.stderr) + return 1 + + try: + proposals, report = run.two_pass(args.paths, jobs=args.jobs, + dry_run=args.dry_run) + except run.RunError as exc: + print(f"docket: {exc}", file=sys.stderr) + return 1 + + for line in report: + print(line) + if args.dry_run: + return 0 + + merged = stage.merge(stage.read(staged), proposals) + stage.write(staged, merged) + print(f"docket: staged {len(merged)} proposals in {staged}") + print("docket: read them with docket construct --review") + return 0 diff --git a/docket/construct/__init__.py b/docket/construct/__init__.py new file mode 100644 index 0000000..f536b61 --- /dev/null +++ b/docket/construct/__init__.py @@ -0,0 +1,5 @@ +"""Bootstrap a ledger from a project's written history. + +Construct never writes to the ledger. It stages proposals, a human accepts +them, and that acceptance is the approval the ledger records. +""" diff --git a/docket/construct/accept.py b/docket/construct/accept.py new file mode 100644 index 0000000..ff4dd6a --- /dev/null +++ b/docket/construct/accept.py @@ -0,0 +1,109 @@ +"""Accepted proposals becoming ledger records. + +The only place construct touches the ledger, and it writes through the ordinary +writer so numbering and locking stay honest. Acceptance is the approval the +ledger exists to record; extraction never is. +""" + +from __future__ import annotations + +from pathlib import Path + +from docket.construct import stage +from docket.ledger import append, make_record + +AUTHOR = "docket-construct" + + +def _order(proposals: list[dict]) -> list[dict]: + """Supported records first, so a key resolves to an id by the time it is + needed. Support is acyclic by then, so a stable pass suffices.""" + by_key = {p["key"]: p for p in proposals} + done: list[dict] = [] + seen: set[str] = set() + + def visit(item: dict) -> None: + if item["key"] in seen: + return + seen.add(item["key"]) + for group in item.get("supports") or []: + for key in group: + if key in by_key: + visit(by_key[key]) + for key in item.get("supersedes") or []: + if key in by_key: + visit(by_key[key]) + done.append(item) + + for item in proposals: + visit(item) + return done + + +def _rationale(item: dict) -> str: + """The record's own rationale, with the document it was read from. + + A later reader has to be able to tell a constructed record from one a human + wrote at the time. + """ + source = item["source"]["path"] + body = (item.get("rationale") or "").strip() + note = f"Constructed from {source}." + return f"{body} {note}".strip() + + +def run(staged: Path, ledger: Path, source: str | None = None) -> tuple[int, int]: + """Write every accepted proposal, and report written and skipped counts. + + Incremental and resumable: each proposal carries its own state, so + accepting one document's records leaves the rest staged. A 520-record + review spans several sittings, and a run that must finish in one pass gets + rubber-stamped instead of read. + """ + proposals = stage.read(staged) + wanted = [p for p in proposals + if p.get("state") == "accepted" + and (source is None or p["source"]["path"] == source)] + + ids: dict[str, str] = {} + written = skipped = 0 + + for item in _order(wanted): + supports = [] + dropped = False + for group in item.get("supports") or []: + resolved = [ids[key] for key in group if key in ids] + if len(resolved) != len(group): + # A record supporting one nobody accepted would enter the + # ledger claiming grounds that are not there. + dropped = True + break + if resolved: + supports.append(resolved) + if dropped: + skipped += 1 + continue + + supersedes = [ids[key] for key in item.get("supersedes") or [] if key in ids] + + record = make_record( + item["kind"], + item["text"], + choice=item.get("choice") or None, + scope=list(item.get("scope") or []), + rationale=_rationale(item), + supports=supports or None, + supersedes=supersedes or None, + author=AUTHOR, + ) + stored = append(ledger, record) + ids[item["key"]] = stored["id"] + item["state"] = "written" + written += 1 + # After each append, never once at the end. There is no transaction + # across N appends, so a failure on append k would otherwise commit k-1 + # records while the stage still calls them accepted. The user would see + # the error, rerun, and append a second copy of every one. + stage.write(staged, proposals) + + return written, skipped diff --git a/docket/construct/client.py b/docket/construct/client.py new file mode 100644 index 0000000..e748ae6 --- /dev/null +++ b/docket/construct/client.py @@ -0,0 +1,140 @@ +"""The provider boundary: what construct sends, and what it accepts back. + +Nothing here imports the SDK. Building a request and checking a response are +pure, so both are testable without a key and without a network. The one +function that talks to OpenRouter imports `openai` inside its own body, which +is what keeps the SessionStart hook free of the dependency. +""" + +from __future__ import annotations + +import json +import os + +SCHEMA_NAME = "docket_construct" + +# OpenRouter is the documented default: one key reaches every provider through +# one endpoint. Gemini serves an OpenAI-compatible endpoint of its own, so the +# same SDK reaches it with only the base URL changed, which is the path for +# someone who already holds a Gemini key. +PROVIDERS = { + "openrouter": { + "env": "OPENROUTER_API_KEY", + "base_url": "https://openrouter.ai/api/v1", + "model": "google/gemini-3.8-flash", + }, + "gemini": { + "env": "GEMINI_API_KEY", + "base_url": "https://generativelanguage.googleapis.com/v1beta/openai/", + "model": "gemini-3.8-flash", + }, +} +BASE_URL = PROVIDERS["openrouter"]["base_url"] +DEFAULT_MODEL = PROVIDERS["openrouter"]["model"] + +MAX_BACKOFF = 60.0 +_BASE_BACKOFF = 1.5 + + +class ClientError(RuntimeError): + """Construct cannot reach a provider at all.""" + + +class SchemaViolation(ValueError): + """A response that did not conform to the schema it was asked for.""" + + +def config(env: dict[str, str] | None = None) -> dict: + """Where to call and as whom, from the environment. + + The first provider whose key is present wins, so OpenRouter stays the + default when both are set. + """ + env = os.environ if env is None else env + for name, spec in PROVIDERS.items(): + key = env.get(spec["env"], "") + if key: + return { + "provider": name, + "api_key": key, + "base_url": env.get("DOCKET_CONSTRUCT_BASE_URL", spec["base_url"]), + "model": env.get("DOCKET_CONSTRUCT_MODEL", spec["model"]), + } + wanted = " or ".join(spec["env"] for spec in PROVIDERS.values()) + raise ClientError( + f"docket construct needs {wanted}. An OpenRouter key reaches every " + "provider through one endpoint; a Gemini key reaches Gemini.") + + +def request(prompt: str, schema: dict, model: str, + provider: str = "openrouter") -> dict: + """The request body for one extraction or linking call. + + On OpenRouter, `require_parameters` is the part that matters. OpenRouter + honours `json_schema` per endpoint rather than per model, so the same model + reached through a different upstream provider may ignore the schema and fall + back to plain JSON. The flag routes only to providers that support it, and + `parse` still checks, because the flag is not a guarantee. + + A single-provider endpoint has nothing to route, so it gets no such field. + """ + body = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "response_format": { + "type": "json_schema", + "json_schema": {"name": SCHEMA_NAME, "strict": True, "schema": schema}, + }, + } + if provider == "openrouter": + body["extra_body"] = {"provider": {"require_parameters": True}} + return body + + +_TYPES = { + "object": dict, "array": list, "string": str, + "number": (int, float), "integer": int, "boolean": bool, +} + + +def parse(text: str, schema: dict) -> dict: + """The response as an object, or a violation naming what was wrong. + + Deliberately shallow: it checks what a silent fallback breaks, which is the + top-level shape and the required keys. A provider that ignored the schema + returns prose or a differently shaped object, and both fail here. + """ + try: + value = json.loads(text) + except json.JSONDecodeError as exc: + raise SchemaViolation(f"response is not JSON: {exc}") from None + + expected = _TYPES.get(schema.get("type", "object"), dict) + if not isinstance(value, expected): + raise SchemaViolation( + f"response is {type(value).__name__}, expected {schema.get('type')}") + + properties = schema.get("properties", {}) + for name in schema.get("required", []): + if name not in value: + raise SchemaViolation(f"response is missing required key {name!r}") + want = _TYPES.get(properties.get(name, {}).get("type")) + if want and not isinstance(value[name], want): + raise SchemaViolation( + f"key {name!r} is {type(value[name]).__name__}, expected " + f"{properties[name]['type']}") + return value + + +def backoff(attempt: int, retry_after: str | None = None) -> float: + """Seconds to wait before retrying, honouring Retry-After when given. + + The extraction pass issues one call per document across a thread pool, so a + 429 is expected rather than exceptional. + """ + if retry_after: + try: + return float(retry_after) + except ValueError: + pass + return min(_BASE_BACKOFF * (2 ** attempt), MAX_BACKOFF) diff --git a/docket/construct/extract.py b/docket/construct/extract.py new file mode 100644 index 0000000..46cb640 --- /dev/null +++ b/docket/construct/extract.py @@ -0,0 +1,120 @@ +"""Locating a proposal in its source, and dating the document it came from. + +Pass 1 runs a model over one document at a time. Everything in this module is +what happens locally around that call: proving the model quoted a real line, +and resolving a date the model is never asked for. +""" + +from __future__ import annotations + +import datetime as _datetime +import re +import subprocess +from pathlib import Path + +from docket.construct.schema import normalize_anchor + +# A date label in the document head, matched after the line is stripped of +# emphasis: the corpus writes both `Date:` and `**Date:**`, the second putting +# the colon inside the markers. The corpus carries no YAML front matter at all; +# 26 of its 372 documents open with a line like this instead. +_DATE_LINE = re.compile(r"^Date\s*:\s*(\d{4}-\d{2}-\d{2})") +_DATE_HEAD_LINES = 15 + +# Anywhere in the filename, not only the front. positioning-2026-05-24.md +# carries its date mid-name, where a leading-date parse finds nothing. +_DATE_NAME = re.compile(r"(\d{4})-(\d{2})-(\d{2})") + + +def anchor_line(anchor: str, source: str) -> int | None: + """The 1-based line the anchor quotes, or None. + + Compares normalized whole lines. A model asked for a verbatim line returns + the words and drops the emphasis around them, so `**Decision:** Option B` + in the source arrives as `Decision: Option B`. Matching raw strings cost + the spike a third of its batch-one anchors. + + Whole lines, never substrings: a three-word anchor matched as a substring + would claim any paragraph containing those words. + """ + wanted = normalize_anchor(anchor) + if not wanted: + return None + for number, line in enumerate(source.splitlines(), start=1): + if normalize_anchor(line) == wanted: + return number + return None + + +def match_rate(proposals: list[dict], source: str) -> tuple[int, int]: + """How many proposals quote a real line, over how many there are. + + The quality signal for one document's extraction. A run whose anchors stop + matching means the model started paraphrasing, and every record from it + needs a human before it is trusted. + """ + matched = sum(1 for p in proposals if anchor_line(p.get("anchor", ""), source)) + return matched, len(proposals) + + +def date_from_text(text: str) -> str | None: + """A date the document states about itself, or None. + + Only the head counts. A date further down is a fact about the subject, + never about the document. + """ + for line in text.splitlines()[:_DATE_HEAD_LINES]: + found = _DATE_LINE.match(normalize_anchor(line)) + if found: + return found.group(1) + return None + + +def date_from_name(name: str) -> str | None: + """A real calendar date in the filename, or None.""" + found = _DATE_NAME.search(name) + if not found: + return None + year, month, day = (int(part) for part in found.groups()) + try: + return _datetime.date(year, month, day).isoformat() + except ValueError: + return None + + +def git_dates(root: Path) -> dict[str, str]: + """Every tracked path under root, mapped to its last commit date. + + One call for the whole tree. 133 of the 372 documents in the corpus this + was built against carry no date in their name or their head, so a + subprocess per file would make dating the slowest part of a run. + """ + try: + done = subprocess.run( + ["git", "-C", str(root), "log", "--name-only", "--date=short", + "--format=%x00%ad", "--no-renames"], + capture_output=True, timeout=60) + except (OSError, subprocess.SubprocessError): + return {} + if done.returncode != 0: + return {} + + dates: dict[str, str] = {} + current = "" + # Newest commit first, so the first date a path appears under is its last. + for line in done.stdout.decode("utf-8", errors="surrogateescape").splitlines(): + if line.startswith("\0"): + current = line[1:].strip() + elif line.strip() and current: + dates.setdefault(line.strip(), current) + return dates + + +def resolve_date(name: str, text: str, git: dict[str, str]) -> str | None: + """The document's date, by the most specific source that has one. + + What the document says about itself beats what its name says, which beats + what git remembers. A document with no date from any source resolves to + None, and pass 2 proposes no supersession edge for its records. + """ + return date_from_text(text) or date_from_name(Path(name).name) or git.get(name) diff --git a/docket/construct/link.py b/docket/construct/link.py new file mode 100644 index 0000000..7500be2 --- /dev/null +++ b/docket/construct/link.py @@ -0,0 +1,229 @@ +"""Pass 2: the relations between proposals, and what the local rules allow. + +A per-document extraction cannot see another document, so it can only ever emit +a flat list. Relations need one call over the whole set. The model proposes +edges; everything here decides which of them survive. +""" + +from __future__ import annotations + +from docket.construct import schema + +# `contradicts` never lands on a record. Two records that disagree become a +# question naming both, because deciding which one survives is the author's +# call and a linker has no standing to make it. +EDGE_KINDS = ("supports", "supersedes", "contradicts") + +# One call over 843 records produced 23 edges. Records from one document relate +# most reliably, so a batch keeps documents whole and lets neighbours co-occur. +BATCH = 120 + +# Sent to the linker in place of the documents. The spike's 61 records are +# about 2.8k tokens like this, against the 15k words they came from. +_PAYLOAD_FIELDS = ("kind", "text", "choice") + + +def _label(index: int) -> str: + return f"p{index + 1}" + + +def payload(proposals: list[dict]) -> list[dict]: + """The proposal set as the linker sees it: labelled, and stripped to the + fields a relation can be argued from.""" + rows = [] + for index, item in enumerate(proposals): + row = {"id": _label(index)} + row.update({field: item.get(field, "") for field in _PAYLOAD_FIELDS}) + row["path"] = item["source"]["path"] + row["date"] = item["source"]["date"] + rows.append(row) + return rows + + +def labels(proposals: list[dict]) -> dict[str, str]: + """Each label mapped to the identity key it stands for.""" + return {_label(index): item["key"] for index, item in enumerate(proposals)} + + +def _reaches(edges: list[tuple[str, str]], start: str, target: str) -> bool: + """Whether target is reachable from start along the given edges.""" + seen = set() + stack = [start] + while stack: + node = stack.pop() + if node == target: + return True + if node in seen: + continue + seen.add(node) + stack.extend(head for tail, head in edges if tail == node) + return False + + +def validate(edges: list[dict], proposals: list[dict]) -> tuple[list[dict], list[str]]: + """The edges that hold, and one message per edge dropped. + + A failing edge is dropped with a warning rather than failing the run: a + linker that gets one relation wrong out of five hundred should not cost the + other four hundred and ninety-nine. + """ + known = {_label(index): item for index, item in enumerate(proposals)} + kept: list[dict] = [] + dropped: list[str] = [] + supports: list[tuple[str, str]] = [] + retired: dict[str, str] = {} + + for edge in edges: + kind, tail, head = edge.get("kind"), edge.get("from"), edge.get("to") + where = f"{kind} {tail} -> {head}" + + if kind not in EDGE_KINDS: + dropped.append(f"{where}: unknown edge kind {kind!r}") + continue + for name in (tail, head): + if name not in known: + dropped.append(f"{where}: no record {name!r}") + break + else: + if tail == head: + dropped.append(f"{where}: a record cannot relate to itself") + continue + + source, target = known[tail], known[head] + + if kind == "contradicts": + # Nothing to check beyond the endpoints: a disagreement needs no + # shared kind, no dates, and no ordering. + kept.append(edge) + continue + + if kind == "supersedes": + if source["kind"] != target["kind"]: + dropped.append(f"{where}: supersession needs one kind, " + f"got {source['kind']} and {target['kind']}") + continue + later, earlier = source["source"]["date"], target["source"]["date"] + if not later or not earlier: + dropped.append(f"{where}: supersession needs a date on both records") + continue + if later <= earlier: + dropped.append(f"{where}: {tail} is earlier than or same-day as " + f"{head}, so it cannot supersede it") + continue + if head in retired: + # The ledger retires a target once and refuses the second + # append. Acceptance has no transaction, so an edge that + # aborts there leaves records written and the stage untouched. + dropped.append(f"{where}: {head} is already superseded by " + f"{retired[head]}") + continue + retired[head] = tail + + if kind == "supports": + if target["kind"] == "question": + # ledger.py refuses support pointing at a question: an + # inquiry is not a ground. + dropped.append(f"{where}: {head} is a question, which cannot " + "ground anything") + continue + # The new edge points tail -> head, so a path from head back to + # tail would close a loop. + if _reaches(supports, head, tail): + dropped.append(f"{where}: closes a support cycle") + continue + supports.append((tail, head)) + + kept.append(edge) + + return kept, dropped + + +def questions(edges: list[dict], proposals: list[dict]) -> list[dict]: + """One proposed question per contradiction, naming both records. + + Two faithful records can disagree because their documents were written + months apart. Recording a silent supersedes would pick a winner the sources + do not; a question puts the choice in front of whoever owns it. + + The question anchors on the first record's source line, so a reviewer still + has a document to open and the key stays stable across runs. + """ + known = {_label(index): item for index, item in enumerate(proposals)} + asked = [] + for edge in edges: + if edge.get("kind") != "contradicts": + continue + first, second = known.get(edge["from"]), known.get(edge["to"]) + if not first or not second: + continue + asked.append(schema.proposal( + kind="question", + text=(f"Which holds? {first['text']} " + f"Against: {second['text']}"), + anchor=first["anchor"], + key_kind="contradiction", + rationale=(f"Extraction found both, from {first['source']['path']} " + f"and {second['source']['path']}. Neither source settles it."), + source=dict(first["source"]), + confidence="low", + )) + return asked + + +def batches(proposals: list[dict], size: int = BATCH) -> list[list[dict]]: + """The proposal set split into linkable chunks, documents kept whole. + + A single call over the whole set does not scale: 843 records yielded 23 + edges. Splitting loses edges that cross a boundary, and keeping each + document intact preserves the ones most likely to be real. + """ + if size <= 0 or len(proposals) <= size: + return [list(proposals)] + + grouped: dict[str, list[dict]] = {} + for item in proposals: + grouped.setdefault(item["source"]["path"], []).append(item) + + out: list[list[dict]] = [] + current: list[dict] = [] + for path in sorted(grouped): + group = grouped[path] + if current and len(current) + len(group) > size: + out.append(current) + current = [] + current.extend(group) + if current: + out.append(current) + return out + + +def apply(edges: list[dict], proposals: list[dict]) -> list[dict]: + """The proposals carrying their validated relations, keyed by identity. + + Support lands as a single justification set. Alternative sets are a thing a + human adds later; a linker has no way to tell two independent grounds from + two halves of one. + """ + key_of = labels(proposals) + # Deep enough to own every list this function appends to, so an input + # record's own relations are never mutated. + out = [{**item, + "supports": [list(group) for group in item.get("supports") or []], + "supersedes": list(item.get("supersedes") or [])} + for item in proposals] + index_of = {_label(index): index for index in range(len(proposals))} + + for edge in edges: + if edge["kind"] == "contradicts": + # Lands on neither record; questions() turns it into its own. + continue + record = out[index_of[edge["from"]]] + target_key = key_of[edge["to"]] + if edge["kind"] == "supports": + # One set per ground. docket/ledger.py reads supports as a list of + # conjunctive sets, so joining two grounds into one set would claim + # both are required, which is more than the linker saw. + record["supports"].append([target_key]) + else: + record["supersedes"].append(target_key) + return out diff --git a/docket/construct/run.py b/docket/construct/run.py new file mode 100644 index 0000000..5bb3db3 --- /dev/null +++ b/docket/construct/run.py @@ -0,0 +1,345 @@ +"""The two-pass driver: documents in, staged proposals out. + +Pass 1 reads one document per call and can only ever emit a flat list. Pass 2 +sees the whole proposal set and proposes the relations between them. Everything +the model returns is checked locally before it is staged. + +The provider call is injected, so the whole driver runs under test without a +key and without a network. +""" + +from __future__ import annotations + +import concurrent.futures +import json +import subprocess +import time +from pathlib import Path + +from docket.construct import client, extract, link, schema + +# Both schemas travel with "strict": True, which requires every property to +# appear in `required` and every object to refuse extra properties. A field the +# document does not supply comes back as "" or [], never absent. +EXTRACT_SCHEMA = { + "type": "object", + "properties": { + "records": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": {"type": "string", "enum": list(schema.KINDS)}, + "text": {"type": "string"}, + "choice": {"type": "string"}, + "rationale": {"type": "string"}, + "anchor": {"type": "string"}, + "scope": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["kind", "text", "choice", "rationale", "anchor", "scope"], + "additionalProperties": False, + }, + }, + }, + "required": ["records"], + "additionalProperties": False, +} + +LINK_SCHEMA = { + "type": "object", + "properties": { + "edges": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": {"type": "string", "enum": list(link.EDGE_KINDS)}, + "from": {"type": "string"}, + "to": {"type": "string"}, + }, + "required": ["kind", "from", "to"], + "additionalProperties": False, + }, + }, + }, + "required": ["edges"], + "additionalProperties": False, +} + +_EXTRACT_PROMPT = """\ +Read the document below and record the claims, decisions and open questions it +states. Record only what the document says; infer nothing it does not. + +A claim is a premise the document asserts. A decision is a commitment it makes, +and needs the choice. A question is something it leaves open. + +For every record, `anchor` must be one line copied verbatim from the document, +the line that carries the record. Do not paraphrase the anchor. + +`scope` decides whether anyone ever sees the record again, so fill it whenever +you can. It lists the files or directories the record governs, written as paths +or globs with no spaces: `src/auth.py`, `src/**`, `alembic/**`. Never a sentence, +never a description of the coverage. + +Take the paths from the document itself: filenames it names, modules it +discusses, directories it points at. A record about an authentication decision +in a project whose code sits under `src/` scopes to `src/auth/**` even when the +document never spells that path out. Leave it empty only when you can name no +part of the tree the record touches. + +Where the document lists options it rejected, put them in `rationale`. Do not +record a rejected option as its own decision. + +Document: {path} + +{body} +""" + +_LINK_PROMPT = """\ +Below are records extracted from one project's documents, each with the document +it came from and that document's date. + +Propose the relations between them. + +`supports` means the second record is a ground for the first. + +`supersedes` means the first record replaces the second. Both must be the same +kind, and the first must come from a later date. + +`contradicts` means the two cannot both hold. Use it when two records disagree +and nothing shown decides which wins; do not pick a winner with `supersedes`. +Records from documents written months apart often disagree this way. + +Propose nothing you cannot argue from the records shown. An empty list is a +valid answer. + +{rows} +""" + + +class RunError(RuntimeError): + """The run cannot proceed at all.""" + + +def documents(paths: list[str]) -> list[Path]: + """Every markdown file the given paths name, in a stable order.""" + found: list[Path] = [] + for name in paths: + path = Path(name) + if path.is_file(): + found.append(path) + elif path.is_dir(): + found.extend(sorted(path.rglob("*.md"))) + return sorted(dict.fromkeys(found)) + + +def _default_caller(): + """The real provider call. Imported here, never at module scope.""" + from openai import OpenAI + + cfg = client.config() + api = OpenAI(api_key=cfg["api_key"], base_url=cfg["base_url"]) + + def call(prompt: str, want: dict) -> dict: + body = client.request(prompt, want, model=cfg["model"], + provider=cfg["provider"]) + reply = api.chat.completions.create(**body) + return client.parse(reply.choices[0].message.content or "", want) + + return call + + +ATTEMPTS = 4 + +# A rate limit reports itself differently per provider, so match the code and +# the words rather than an exception type the SDK may or may not raise. +_RATE_LIMITED = ("429", "rate limit", "resource_exhausted", "too many requests") + + +def _rate_limited(exc: Exception) -> bool: + text = str(exc).lower() + return any(mark in text for mark in _RATE_LIMITED) + + +def _with_retry(call, sleep) -> dict: + """One call, retried while the provider says it is rate limited. + + Extraction issues one call per document across a pool, so a 429 is expected. + Without this, one rate limit costs a whole document's records. + """ + for attempt in range(ATTEMPTS): + try: + return call() + except Exception as exc: + if attempt == ATTEMPTS - 1 or not _rate_limited(exc): + raise + sleep(client.backoff(attempt)) + raise AssertionError("unreachable") + + +def _one_document(path: str, text: str, date: str | None, caller, + sleep=time.sleep) -> tuple[list[dict], list[str]]: + """Pass 1 for one document: the records that survive local checks.""" + prompt = _EXTRACT_PROMPT.format(path=path, body=text) + reply = _with_retry(lambda: caller(prompt, EXTRACT_SCHEMA), sleep) + raw = reply.get("records", []) + kept: list[dict] = [] + notes: list[str] = [] + + for record in raw: + anchor = record.get("anchor", "") + line = extract.anchor_line(anchor, text) + if line is None: + notes.append(f"{path}: dropped a record, its anchor quotes no line " + f"in the document: {anchor[:60]!r}") + continue + + scope = list(record.get("scope") or []) + bad = schema.invalid_scope(scope) + if bad: + notes.append(f"{path}: dropped {len(bad)} scope entr" + f"{'y' if len(bad) == 1 else 'ies'} that name no file") + scope = [item for item in scope if item not in bad] + + try: + item = schema.proposal( + kind=record.get("kind", ""), + text=record.get("text", ""), + anchor=anchor, + choice=record.get("choice", "") or "", + rationale=record.get("rationale", "") or "", + scope=scope, + source={"path": path, "date": date}, + line=line, + ) + except schema.SchemaError as exc: + notes.append(f"{path}: dropped a record, {exc}") + continue + kept.append(item) + + matched, total = extract.match_rate(raw, text) + notes.append(f"{path}: {matched}/{total} anchors matched") + return kept, notes + + +def _link(proposals: list[dict], caller, batch: int) -> tuple[list[dict], list[str]]: + """Pass 2: the relations, validated locally before they are applied. + + One call per batch. A single call over the whole set does not scale, and a + batch that loses a boundary-crossing edge still keeps every document whole. + """ + groups = link.batches(proposals, size=batch) + notes = [f"link: {len(groups)} batch{'' if len(groups) == 1 else 'es'}"] + linked: list[dict] = [] + asked: list[dict] = [] + kept = 0 + + for group in groups: + rows = json.dumps(link.payload(group), indent=2) + try: + reply = caller(_LINK_PROMPT.format(rows=rows), LINK_SCHEMA) + except Exception as exc: + notes.append(f"link: a batch failed, {exc}; its records carry no relations") + linked.extend(group) + continue + edges, dropped = link.validate(reply.get("edges", []), group) + notes.extend(f"link: dropped {edge}" for edge in dropped) + kept += len(edges) + asked.extend(link.questions(edges, group)) + linked.extend(link.apply(edges, group)) + + notes.append(f"link: kept {kept} edge{'' if kept == 1 else 's'}") + if asked: + notes.append(f"link: asked {len(asked)} question" + f"{'' if len(asked) == 1 else 's'} about contradictions") + return linked + asked, notes + + +def _repo_root(found: list[Path], fallback: Path) -> Path: + """The repository holding the documents. + + Never the working directory: reading another project's history is the main + case, and there cwd names a repository the documents are not in. Both the + identity key and the git date lookup are relative to this. + """ + start = found[0].resolve().parent if found else fallback + try: + done = subprocess.run(["git", "-C", str(start), "rev-parse", "--show-toplevel"], + capture_output=True, text=True, timeout=10) + except (OSError, subprocess.SubprocessError): + return fallback + if done.returncode != 0: + return fallback + return Path(done.stdout.strip()).resolve() + + +def _relative(path: Path, root: Path) -> str: + """The path as the repository names it. + + Both the identity key and the git date lookup hinge on this spelling. An + absolute path would restage every record as new on the next run, and would + miss every git date, which silently disables supersession. + """ + try: + return str(path.resolve().relative_to(root)) + except ValueError: + return str(path) + + +def two_pass(paths: list[str], jobs: int = 8, dry_run: bool = False, + caller=None, batch: int = link.BATCH, + root: Path | None = None, sleep=time.sleep) -> tuple[list[dict], list[str]]: + """Both passes over the given documents. + + A document whose call fails costs only its own records. One provider error + out of 180 should not discard the other 179. + """ + found = documents(paths) + if not found: + raise RunError(f"no markdown found under {', '.join(paths)}") + + report = [f"read {len(found)} document{'' if len(found) == 1 else 's'}"] + if dry_run: + report.extend(f" would read {path}" for path in found[:20]) + if len(found) > 20: + report.append(f" ... and {len(found) - 20} more") + return [], report + + caller = caller or _default_caller() + root = (root or _repo_root(found, Path.cwd())).resolve() + git = extract.git_dates(root) + bodies = {path: path.read_text(errors="replace") for path in found} + names = {path: _relative(path, root) for path in found} + dates = {path: extract.resolve_date(names[path], bodies[path], git) + for path in found} + + proposals: list[dict] = [] + with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, jobs)) as pool: + futures = { + pool.submit(_one_document, names[path], bodies[path], dates[path], + caller, sleep): path + for path in found + } + for future in concurrent.futures.as_completed(futures): + path = futures[future] + try: + kept, notes = future.result() + except Exception as exc: + report.append(f"{path}: extraction failed, {exc}") + continue + proposals.extend(kept) + report.extend(notes) + + proposals.sort(key=lambda item: (item["source"]["path"], item.get("line", 0))) + + if len(proposals) < 2: + report.append("link: skipped, nothing to relate") + return proposals, report + + try: + proposals, notes = _link(proposals, caller, batch) + except Exception as exc: + report.append(f"link: failed, {exc}; proposals staged without relations") + return proposals, report + report.extend(notes) + return proposals, report diff --git a/docket/construct/schema.py b/docket/construct/schema.py new file mode 100644 index 0000000..28e1200 --- /dev/null +++ b/docket/construct/schema.py @@ -0,0 +1,120 @@ +"""The shape of a staged proposal, and the checks that run without a model. + +Everything here is local. Extraction returns prose the model chose, and these +rules decide what is usable before a human is asked to read it. +""" + +from __future__ import annotations + +import hashlib +import re + +KINDS = ("claim", "decision", "question") +STATES = ("staged", "accepted", "rejected", "written") +CONFIDENCE = ("low", "medium", "high") + +SCOPE_MAX = 200 + +# Asterisk and backtick are always markup here. Underscore is not: the corpus +# this was built against carries 62162 identifier underscores against 9 uses of +# _emphasis_, and stripping them merges set_timeout with settimeout. A merged +# anchor silently collapses two records into one. +_EMPHASIS = re.compile(r"[*`]+") +_SPACE = re.compile(r"\s+") + +# A scope entry matching the whole tree makes its record surface in every +# briefing, which is worse than carrying no scope at all. +_EVERYTHING = {"*", "**", "*/*", "**/*", "**/**", "./**"} + + +class SchemaError(ValueError): + """A proposal the local rules reject.""" + + +def normalize_anchor(text: str) -> str: + """The anchor reduced to what survives a model's formatting choices. + + A model asked for a verbatim line returns the words and drops the emphasis + around them: the source reads `**Decision:** Option B` and the anchor comes + back as `Decision: Option B`. Matching on the raw string missed a third of + batch one for that reason alone. + """ + return _SPACE.sub(" ", _EMPHASIS.sub("", text)).strip() + + +def identity(source_path: str, anchor: str, kind: str = "") -> str: + """The key that survives re-extraction. + + Extraction is not deterministic, so the model's own wording cannot key a + record across runs. The anchor is verbatim source text and the path pins + which document it came from. A NUL joins them because no path contains one, + so no pair of (path, anchor) can collide by concatenation. + + `kind` separates a derived record from the one it borrowed from. A + contradiction question takes another record's anchor and source so a reviewer + still has a line to open, and without this it would key identically and one + of the two would be dropped as a duplicate. + """ + material = f"{source_path}\0{normalize_anchor(anchor)}\0{kind}".encode() + return hashlib.sha256(material).hexdigest() + + +def invalid_scope(scope: list[str]) -> list[str]: + """The scope entries that cannot address a file, in order. + + A model asked for a scope sometimes describes the coverage instead of + naming it; the spike returned a 600-character paragraph ending in a glob. + Scope feeds path matching, so an entry that is not a path is not a scope. + """ + bad = [] + for item in scope: + if (not item + or len(item) > SCOPE_MAX + or _SPACE.search(item) + or item in _EVERYTHING + or item.startswith("/") + or ".." in item.split("/")): + bad.append(item) + return bad + + +def proposal(kind: str, text: str, anchor: str, source: dict, + choice: str = "", rationale: str = "", scope: list[str] | None = None, + confidence: str = "low", key_kind: str = "", **extra) -> dict: + """One staged record, keyed and checked. + + `key_kind` separates a record derived from another's anchor, so the two do + not key identically. See `identity`. + """ + if kind not in KINDS: + raise SchemaError(f"unknown kind {kind!r}; expected one of {', '.join(KINDS)}") + if not text.strip(): + raise SchemaError("a proposal needs text") + if not anchor.strip(): + raise SchemaError("a proposal needs an anchor: it is the identity key " + "and the reviewer's way back to the source") + if kind == "decision" and not choice.strip(): + raise SchemaError("a decision needs a choice") + if confidence not in CONFIDENCE: + raise SchemaError(f"unknown confidence {confidence!r}") + if not source.get("path"): + raise SchemaError("a proposal needs its source path") + + scope = list(scope or []) + bad = invalid_scope(scope) + if bad: + raise SchemaError(f"scope entries do not address a file: {bad!r}") + + return { + "key": identity(source["path"], anchor, kind=key_kind), + "kind": kind, + "text": text, + "choice": choice, + "rationale": rationale, + "scope": scope, + "anchor": anchor, + "source": {"path": source["path"], "date": source.get("date")}, + "confidence": confidence, + "state": "staged", + **extra, + } diff --git a/docket/construct/stage.py b/docket/construct/stage.py new file mode 100644 index 0000000..91be0fc --- /dev/null +++ b/docket/construct/stage.py @@ -0,0 +1,119 @@ +"""The proposal file, and what a human sees before accepting from it. + +Construct stages here and stops. Acceptance is a separate step, because the +ledger records approvals and nothing else. +""" + +from __future__ import annotations + +import fnmatch +import json +from pathlib import Path + +from docket.construct.schema import CONFIDENCE, STATES + +PROPOSED = Path(".docket/proposed.jsonl") + +# Reviewed in this order, so a reader meets the strongest records first. +_RANK = {name: index for index, name in enumerate(reversed(CONFIDENCE))} + + +class StageError(ValueError): + """The staging file cannot be read as proposals.""" + + +def read(path: Path) -> list[dict]: + """Staged proposals, or nothing when the file does not exist yet. + + Marking proposals accepted means editing this file by hand, so a bad edit + has to name the line rather than raise a decode error at whoever made it. + """ + if not path.exists(): + return [] + items = [] + for number, line in enumerate(path.read_text().splitlines(), start=1): + if not line.strip(): + continue + try: + item = json.loads(line) + except json.JSONDecodeError as exc: + raise StageError(f"{path}: line {number} is not JSON: {exc}") from None + if not isinstance(item, dict): + raise StageError(f"{path}: line {number} is not an object") + for field in ("key", "state", "kind"): + if field not in item: + raise StageError(f"{path}: line {number} has no {field!r}") + if item["state"] not in STATES: + raise StageError(f"{path}: line {number} has state {item['state']!r}; " + f"expected one of {', '.join(STATES)}") + items.append(item) + return items + + +def write(path: Path, proposals: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + body = "\n".join(json.dumps(p, ensure_ascii=False) for p in proposals) + path.write_text(body + "\n" if body else "") + + +def merge(existing: list[dict], fresh: list[dict]) -> list[dict]: + """Fresh extraction, carrying forward what a human already decided. + + Keyed on the identity key, so the model's own wording can change between + runs without losing an acceptance. A record whose anchor changed gets a new + key and stages again, which is correct: the source text it was accepted + against no longer exists. + """ + decided = {p["key"]: p["state"] for p in existing if p["state"] != "staged"} + merged = [] + for item in fresh: + item = dict(item) + item["state"] = decided.get(item["key"], "staged") + merged.append(item) + return merged + + +def resolves(proposal: dict, live: set[str]) -> bool: + """Whether any scope entry addresses a file that still exists. + + A record whose scope matches nothing can never reach a briefing, so this + decides review order. It never drops a record: a stale path can mean the + record is dead, and it can equally mean the record is the only surviving + account of a rename. + """ + for item in proposal.get("scope") or []: + for path in live: + if path == item or fnmatch.fnmatchcase(path, item): + return True + return False + + +def resolution_rate(proposals: list[dict], live: set[str]) -> tuple[int, int]: + """How many scoped records address live files, over how many are scoped. + + The acceptance signal for a whole run. A run resolving at 70% produces a + ledger that mostly points at live code; one at 20% is building a museum, + and the answer is to narrow the input set. + """ + scoped = [p for p in proposals if p.get("scope")] + return sum(1 for p in scoped if resolves(p, live)), len(scoped) + + +def review_groups(proposals: list[dict], live: set[str]) -> list[tuple[str, list[dict]]]: + """Staged proposals by source document, strongest first within each. + + Nobody reads 520 proposals in order, so there is no linear reading order to + preserve. Records whose scope resolves to nothing sink to the bottom of + their group rather than disappearing. + """ + groups: dict[str, list[dict]] = {} + for item in proposals: + if item.get("state") != "staged": + continue + groups.setdefault(item["source"]["path"], []).append(item) + + def order(item: dict) -> tuple[int, int]: + return (0 if resolves(item, live) else 1, + _RANK.get(item.get("confidence", "low"), len(CONFIDENCE))) + + return [(name, sorted(items, key=order)) for name, items in sorted(groups.items())] diff --git a/tests/test_construct_accept.py b/tests/test_construct_accept.py new file mode 100644 index 0000000..ab1d270 --- /dev/null +++ b/tests/test_construct_accept.py @@ -0,0 +1,252 @@ +"""Acceptance: staged proposals becoming ledger records.""" + +import json +import tempfile +import unittest +from pathlib import Path + +from docket.construct import accept, schema, stage +from docket.ledger import project, read + + +def prop(anchor, kind="decision", choice="yes", text="Question?", date="2026-06-18", + path="a.md", scope=None, rationale="because"): + return schema.proposal(kind=kind, text=text, choice=choice, anchor=anchor, + rationale=rationale, scope=scope or [], + source={"path": path, "date": date}) + + +class AcceptTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.ledger = Path(self.tmp.name) / "ledger.jsonl" + self.staged = Path(self.tmp.name) / "proposed.jsonl" + + def accepted(self, *items): + marked = [] + for item in items: + item = dict(item) + item["state"] = "accepted" + marked.append(item) + return marked + + def test_writes_an_accepted_proposal_to_the_ledger(self): + stage.write(self.staged, self.accepted(prop("one"))) + accept.run(self.staged, self.ledger) + entries = read(self.ledger) + self.assertEqual(len(entries), 1) + self.assertEqual(entries[0]["text"], "Question?") + + def test_allocates_a_real_ledger_id(self): + stage.write(self.staged, self.accepted(prop("one"))) + accept.run(self.staged, self.ledger) + self.assertEqual(read(self.ledger)[0]["id"], "d1") + + def test_leaves_a_staged_proposal_alone(self): + stage.write(self.staged, [prop("one")]) + accept.run(self.staged, self.ledger) + self.assertEqual(read(self.ledger), []) + + def test_leaves_a_rejected_proposal_alone(self): + item = dict(prop("one")) + item["state"] = "rejected" + stage.write(self.staged, [item]) + accept.run(self.staged, self.ledger) + self.assertEqual(read(self.ledger), []) + + def test_names_the_source_document_in_the_rationale(self): + # A later reader has to be able to tell a constructed record from one a + # human wrote at the time. + stage.write(self.staged, self.accepted(prop("one", path="context/x.md"))) + accept.run(self.staged, self.ledger) + self.assertIn("context/x.md", read(self.ledger)[0]["rationale"]) + + def test_records_the_run_as_the_author(self): + stage.write(self.staged, self.accepted(prop("one"))) + accept.run(self.staged, self.ledger) + self.assertEqual(read(self.ledger)[0]["author"], "docket-construct") + + def test_a_second_run_writes_nothing_again(self): + stage.write(self.staged, self.accepted(prop("one"))) + accept.run(self.staged, self.ledger) + accept.run(self.staged, self.ledger) + self.assertEqual(len(read(self.ledger)), 1) + + def test_marks_a_written_proposal_so_it_is_not_written_twice(self): + stage.write(self.staged, self.accepted(prop("one"))) + accept.run(self.staged, self.ledger) + self.assertEqual(stage.read(self.staged)[0]["state"], "written") + + def test_accepts_only_the_named_source_when_asked(self): + stage.write(self.staged, self.accepted(prop("a", path="x.md"), + prop("b", path="y.md"))) + accept.run(self.staged, self.ledger, source="x.md") + self.assertEqual([e["text"] for e in read(self.ledger)], ["Question?"]) + states = {p["source"]["path"]: p["state"] for p in stage.read(self.staged)} + self.assertEqual(states, {"x.md": "written", "y.md": "accepted"}) + + def test_carries_scope_through(self): + stage.write(self.staged, self.accepted(prop("one", scope=["src/**"]))) + accept.run(self.staged, self.ledger) + self.assertEqual(read(self.ledger)[0]["scope"], ["src/**"]) + + +class RelationTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.ledger = Path(self.tmp.name) / "ledger.jsonl" + self.staged = Path(self.tmp.name) / "proposed.jsonl" + + def test_rewrites_a_support_key_into_the_allocated_id(self): + # Staged edges point at identity keys. The ledger speaks in ids, which + # only exist once a record is written. + base = prop("root", kind="claim", choice="") + child = prop("child") + child["supports"] = [[base["key"]]] + for item in (base, child): + item["state"] = "accepted" + stage.write(self.staged, [base, child]) + accept.run(self.staged, self.ledger) + entries = {e["kind"]: e for e in read(self.ledger)} + self.assertEqual(entries["decision"]["supports"], [[entries["claim"]["id"]]]) + + def test_skips_a_record_whose_support_was_not_accepted(self): + # Writing it with the support quietly dropped would turn a grounded + # record into a free-standing one. Its grounds are part of what it says. + base = prop("root", kind="claim", choice="") + child = prop("child") + child["supports"] = [[base["key"]]] + child["state"] = "accepted" + stage.write(self.staged, [base, child]) + accept.run(self.staged, self.ledger) + self.assertEqual(read(self.ledger), []) + self.assertEqual(stage.read(self.staged)[1]["state"], "accepted") + + def test_rewrites_supersedes_into_the_allocated_id(self): + old = prop("old", date="2026-01-01") + new = prop("new", date="2026-06-01") + new["supersedes"] = [old["key"]] + for item in (old, new): + item["state"] = "accepted" + stage.write(self.staged, [old, new]) + accept.run(self.staged, self.ledger) + entries = sorted(read(self.ledger), key=lambda e: e["id"]) + self.assertEqual(entries[1]["supersedes"], [entries[0]["id"]]) + + def test_a_supported_record_is_written_before_the_one_supporting_it(self): + base = prop("root", kind="claim", choice="") + child = prop("child") + child["supports"] = [[base["key"]]] + for item in (base, child): + item["state"] = "accepted" + # Reverse order on the stage; acceptance must still resolve the key. + stage.write(self.staged, [child, base]) + accept.run(self.staged, self.ledger) + entries = {e["kind"]: e for e in read(self.ledger)} + self.assertEqual(entries["decision"]["supports"], [[entries["claim"]["id"]]]) + + def test_the_written_ledger_projects_cleanly(self): + base = prop("root", kind="claim", choice="") + child = prop("child") + child["supports"] = [[base["key"]]] + for item in (base, child): + item["state"] = "accepted" + stage.write(self.staged, [base, child]) + accept.run(self.staged, self.ledger) + self.assertEqual(len(project(read(self.ledger))), 2) + + +class CrashSafetyTests(unittest.TestCase): + """A failed append must not leave the stage claiming nothing was written. + + There is no transaction across N appends, so the stage has to record each + one as it lands. Otherwise the user sees an error, reruns --accept, and + appends a second copy of everything already written. + """ + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.ledger = Path(self.tmp.name) / "ledger.jsonl" + self.staged = Path(self.tmp.name) / "proposed.jsonl" + + def three(self): + items = [dict(prop(f"anchor {i}")) for i in range(3)] + for item in items: + item["state"] = "accepted" + return items + + def test_a_failing_append_leaves_the_earlier_ones_marked_written(self): + stage.write(self.staged, self.three()) + calls = {"n": 0} + real = accept.append + + def flaky(ledger, record): + calls["n"] += 1 + if calls["n"] == 3: + raise RuntimeError("disk went away") + return real(ledger, record) + + accept.append = flaky + try: + with self.assertRaises(RuntimeError): + accept.run(self.staged, self.ledger) + finally: + accept.append = real + + self.assertEqual(len(read(self.ledger)), 2) + states = [p["state"] for p in stage.read(self.staged)] + self.assertEqual(states.count("written"), 2) + + def test_rerunning_after_a_failure_writes_only_what_is_left(self): + stage.write(self.staged, self.three()) + calls = {"n": 0} + real = accept.append + + def flaky(ledger, record): + calls["n"] += 1 + if calls["n"] == 3: + raise RuntimeError("disk went away") + return real(ledger, record) + + accept.append = flaky + try: + with self.assertRaises(RuntimeError): + accept.run(self.staged, self.ledger) + finally: + accept.append = real + + accept.run(self.staged, self.ledger) + # Three records total, never five. + self.assertEqual(len(read(self.ledger)), 3) + + +class ReportTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.ledger = Path(self.tmp.name) / "ledger.jsonl" + self.staged = Path(self.tmp.name) / "proposed.jsonl" + + def test_reports_how_many_records_it_wrote(self): + items = [prop("a"), prop("b")] + for item in items: + item["state"] = "accepted" + stage.write(self.staged, items) + written, skipped = accept.run(self.staged, self.ledger) + self.assertEqual((written, skipped), (2, 0)) + + def test_counts_a_record_with_missing_grounds_as_skipped(self): + base = prop("root", kind="claim", choice="") + child = prop("child") + child["supports"] = [[base["key"]]] + child["state"] = "accepted" + stage.write(self.staged, [base, child]) + written, skipped = accept.run(self.staged, self.ledger) + self.assertEqual((written, skipped), (0, 1)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_construct_cli.py b/tests/test_construct_cli.py new file mode 100644 index 0000000..df1aa19 --- /dev/null +++ b/tests/test_construct_cli.py @@ -0,0 +1,260 @@ +"""The construct subcommand. Only the paths that need no model.""" + +import argparse +import io +import sys +import tempfile +import unittest +from contextlib import redirect_stdout, redirect_stderr +from pathlib import Path + +from docket.cli import construct as cli_construct +from docket.construct import schema, stage +from docket.ledger import read + + +def prop(anchor, path="context/a.md", confidence="low", scope=None, text="Question?"): + return schema.proposal(kind="decision", text=text, choice="yes", anchor=anchor, + rationale="because", scope=scope or [], confidence=confidence, + source={"path": path, "date": "2026-06-18"}) + + +def args(**over): + fields = {"paths": [], "review": False, "accept": False, "source": None, + "jobs": 4, "dry_run": False} + fields.update(over) + return argparse.Namespace(**fields) + + +class ReviewTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.staged = Path(self.tmp.name) / "proposed.jsonl" + + def review(self, items): + stage.write(self.staged, items) + out = io.StringIO() + with redirect_stdout(out): + rc = cli_construct.review(self.staged, live=set()) + return rc, out.getvalue() + + def test_says_so_when_nothing_is_staged(self): + rc, out = self.review([]) + self.assertEqual(rc, 0) + self.assertIn("nothing staged", out) + + def test_prints_the_source_document_as_a_heading(self): + rc, out = self.review([prop("one")]) + self.assertIn("context/a.md", out) + + def test_prints_the_anchor_so_a_reader_can_find_the_line(self): + rc, out = self.review([prop("**Decision:** keep it")]) + self.assertIn("**Decision:** keep it", out) + + def test_prints_the_record_text(self): + rc, out = self.review([prop("one", text="Where does it live?")]) + self.assertIn("Where does it live?", out) + + def test_marks_a_record_whose_scope_resolves_to_nothing(self): + rc, out = self.review([prop("one", scope=["gone/x.py"])]) + self.assertIn("unresolved", out) + + def test_reports_the_resolution_rate(self): + rc, out = self.review([prop("one", scope=["gone/x.py"])]) + self.assertIn("0/1", out) + + +class AcceptDispatchTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.staged = Path(self.tmp.name) / "proposed.jsonl" + self.ledger = Path(self.tmp.name) / "ledger.jsonl" + + def run_accept(self, items, **over): + stage.write(self.staged, items) + out = io.StringIO() + with redirect_stdout(out): + rc = cli_construct.accept_staged(self.staged, self.ledger, **over) + return rc, out.getvalue() + + def test_writes_accepted_records_and_reports_the_count(self): + item = prop("one") + item["state"] = "accepted" + rc, out = self.run_accept([item]) + self.assertEqual(rc, 0) + self.assertEqual(len(read(self.ledger)), 1) + self.assertIn("1", out) + + def test_a_second_run_is_a_no_op(self): + item = prop("one") + item["state"] = "accepted" + self.run_accept([item]) + out = io.StringIO() + with redirect_stdout(out): + rc = cli_construct.accept_staged(self.staged, self.ledger) + self.assertEqual(rc, 0) + self.assertEqual(len(read(self.ledger)), 1) + + def test_says_so_when_nothing_is_accepted_yet(self): + rc, out = self.run_accept([prop("one")]) + self.assertEqual(rc, 0) + self.assertIn("nothing accepted", out) + + +class FlagTests(unittest.TestCase): + def test_review_and_accept_together_are_refused(self): + err = io.StringIO() + with redirect_stderr(err): + rc = cli_construct.cmd_construct(args(review=True, accept=True)) + self.assertEqual(rc, 2) + self.assertIn("--review", err.getvalue()) + + def test_paths_with_accept_are_refused(self): + # Extraction and acceptance are separate steps by design; running both + # in one command would accept records nobody has read. + err = io.StringIO() + with redirect_stderr(err): + rc = cli_construct.cmd_construct(args(paths=["context/"], accept=True)) + self.assertEqual(rc, 2) + + def test_no_paths_and_no_flag_is_refused(self): + err = io.StringIO() + with redirect_stderr(err): + rc = cli_construct.cmd_construct(args()) + self.assertEqual(rc, 2) + + +class StagedPathTests(unittest.TestCase): + def test_stages_in_the_project_when_it_has_a_docket_directory(self): + # Construct's main case is a project whose ledger is absent, where + # ledger_path() answers with the global store. Staging proposals there + # puts them somewhere the user never looks. + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".git").mkdir() + (root / ".docket").mkdir() + original = cli_construct.env.project_root + try: + cli_construct.env.project_root = lambda start=None: root + self.assertEqual(cli_construct._staged_path(), + root / ".docket" / "proposed.jsonl") + finally: + cli_construct.env.project_root = original + + def test_falls_back_to_the_ledger_directory(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + ledger = root / "global" / "ledger.jsonl" + ledger.parent.mkdir(parents=True) + originals = (cli_construct.env.project_root, cli_construct.env.ledger_path) + try: + cli_construct.env.project_root = lambda start=None: root + cli_construct.env.ledger_path = lambda start=None: ledger + self.assertEqual(cli_construct._staged_path(), + ledger.parent / "proposed.jsonl") + finally: + (cli_construct.env.project_root, + cli_construct.env.ledger_path) = originals + + +class LedgerPairingTests(unittest.TestCase): + def test_accept_writes_beside_the_staging_file(self): + # Staging prefers a project's own .docket; acceptance must target the + # same project, never the global store. Splitting them puts proposals + # in one place and the records they became in another. + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / ".git").mkdir() + (root / ".docket").mkdir() + original = cli_construct.env.project_root + try: + cli_construct.env.project_root = lambda start=None: root + self.assertEqual(cli_construct._ledger_path().parent, + cli_construct._staged_path().parent) + finally: + cli_construct.env.project_root = original + + +class MalformedStageTests(unittest.TestCase): + """Hand-editing the stage is the documented workflow, so a bad edit has to + report itself instead of raising a traceback.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.staged = Path(self.tmp.name) / "proposed.jsonl" + + def test_a_broken_line_names_the_file_and_the_line(self): + self.staged.write_text( + '{"key": "a", "state": "staged", "kind": "claim"}\nnot json\n') + err = io.StringIO() + with redirect_stderr(err): + rc = cli_construct.review(self.staged, live=set()) + self.assertEqual(rc, 1) + self.assertIn("line 2", err.getvalue()) + + def test_a_record_missing_its_state_is_reported(self): + self.staged.write_text('{"key": "a", "kind": "claim"}\n') + err = io.StringIO() + with redirect_stderr(err): + rc = cli_construct.review(self.staged, live=set()) + self.assertEqual(rc, 1) + self.assertIn("state", err.getvalue()) + + +class ReviewSafetyTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.staged = Path(self.tmp.name) / "proposed.jsonl" + + def test_strips_control_characters_from_model_text(self): + # Everything in a proposal came from a model. An escape sequence would + # otherwise reach the terminal verbatim. + item = prop("one", text="red \x1b[31mALERT\x1b[0m here") + stage.write(self.staged, [item]) + out = io.StringIO() + with redirect_stdout(out): + cli_construct.review(self.staged, live=set()) + self.assertNotIn("\x1b", out.getvalue()) + self.assertIn("ALERT", out.getvalue()) + + def test_truncates_text_too_long_to_read(self): + item = prop("one", text="x" * 5000) + stage.write(self.staged, [item]) + out = io.StringIO() + with redirect_stdout(out): + cli_construct.review(self.staged, live=set()) + self.assertLess(len(out.getvalue()), 3000) + + +class DryRunTests(unittest.TestCase): + def test_dry_run_needs_no_sdk(self): + # It reads no document and issues no call, so demanding the dependency + # would refuse the one command someone runs to see what would happen. + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / "a.md").write_text("# a\n") + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + rc = cli_construct.cmd_construct(args(paths=[tmp], dry_run=True)) + self.assertEqual(rc, 0) + self.assertNotIn("pip install", err.getvalue()) + self.assertIn("1 document", out.getvalue()) + + +class DependencyTests(unittest.TestCase): + def test_a_missing_sdk_names_the_install_command(self): + # Every other command, and the SessionStart hook, keep working on a + # machine that never installs it. + message = cli_construct.MISSING_SDK + self.assertIn("pip install", message) + self.assertIn("openai", message) + + def test_importing_the_command_module_does_not_import_the_sdk(self): + self.assertNotIn("openai", sys.modules) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_construct_client.py b/tests/test_construct_client.py new file mode 100644 index 0000000..f53f912 --- /dev/null +++ b/tests/test_construct_client.py @@ -0,0 +1,121 @@ +"""The provider boundary. No network: the SDK is never imported here.""" + +import unittest + +from docket.construct import client + + +class ConfigTests(unittest.TestCase): + def test_reads_the_openrouter_key_from_the_environment(self): + cfg = client.config({"OPENROUTER_API_KEY": "sk-or-x"}) + self.assertEqual(cfg["api_key"], "sk-or-x") + self.assertIn("openrouter.ai", cfg["base_url"]) + + def test_refuses_to_run_without_a_key(self): + with self.assertRaises(client.ClientError) as caught: + client.config({}) + # The message has to name the variables; an operator running a one-time + # bootstrap has no other clue what is missing. + self.assertIn("OPENROUTER_API_KEY", str(caught.exception)) + self.assertIn("GEMINI_API_KEY", str(caught.exception)) + + def test_a_model_can_be_overridden(self): + self.assertEqual(client.config({"OPENROUTER_API_KEY": "k", + "DOCKET_CONSTRUCT_MODEL": "x/y"})["model"], + "x/y") + + def test_has_a_default_model(self): + self.assertTrue(client.config({"OPENROUTER_API_KEY": "k"})["model"]) + + def test_falls_back_to_a_gemini_key(self): + # Gemini serves an OpenAI-compatible endpoint, so the same SDK reaches + # it with only the base URL changed. + cfg = client.config({"GEMINI_API_KEY": "AQ.x"}) + self.assertEqual(cfg["api_key"], "AQ.x") + self.assertIn("generativelanguage.googleapis.com", cfg["base_url"]) + self.assertEqual(cfg["provider"], "gemini") + + def test_a_gemini_model_carries_no_provider_prefix(self): + # OpenRouter names it google/gemini-...; Gemini's own endpoint does not. + self.assertNotIn("/", client.config({"GEMINI_API_KEY": "k"})["model"]) + + def test_openrouter_wins_when_both_keys_are_present(self): + cfg = client.config({"OPENROUTER_API_KEY": "a", "GEMINI_API_KEY": "b"}) + self.assertEqual(cfg["provider"], "openrouter") + + +class RequestTests(unittest.TestCase): + SCHEMA = {"type": "object", "properties": {"records": {"type": "array"}}, + "required": ["records"]} + + def test_routes_only_to_providers_that_honour_the_schema(self): + # OpenRouter enforces json_schema per endpoint, not per model, and some + # providers silently fall back to json_object (c83). + body = client.request("prompt", self.SCHEMA, model="x/y") + self.assertIs(body["extra_body"]["provider"]["require_parameters"], True) + + def test_sends_no_routing_hint_to_a_single_provider(self): + # require_parameters is OpenRouter's own field. Gemini's endpoint has + # one provider, so there is nothing to route and nothing to ask for. + body = client.request("prompt", self.SCHEMA, model="gemini-3.8-flash", + provider="gemini") + self.assertNotIn("extra_body", body) + + def test_asks_for_the_schema_by_name_and_strictly(self): + body = client.request("prompt", self.SCHEMA, model="x/y") + fmt = body["response_format"] + self.assertEqual(fmt["type"], "json_schema") + self.assertTrue(fmt["json_schema"]["name"]) + self.assertIs(fmt["json_schema"]["strict"], True) + self.assertEqual(fmt["json_schema"]["schema"], self.SCHEMA) + + def test_sends_the_prompt_as_the_only_user_message(self): + body = client.request("extract this", self.SCHEMA, model="x/y") + self.assertEqual([m["role"] for m in body["messages"]], ["user"]) + self.assertEqual(body["messages"][0]["content"], "extract this") + + +class ParseTests(unittest.TestCase): + SCHEMA = {"type": "object", "properties": {"records": {"type": "array"}}, + "required": ["records"]} + + def test_returns_the_parsed_object(self): + self.assertEqual(client.parse('{"records": [1]}', self.SCHEMA), + {"records": [1]}) + + def test_rejects_a_response_that_is_not_json(self): + # The silent json_object fallback looks exactly like this. + with self.assertRaises(client.SchemaViolation): + client.parse("Here are the records you asked for.", self.SCHEMA) + + def test_rejects_json_missing_a_required_key(self): + with self.assertRaises(client.SchemaViolation): + client.parse('{"other": []}', self.SCHEMA) + + def test_rejects_a_json_array_where_an_object_was_required(self): + with self.assertRaises(client.SchemaViolation): + client.parse('[1, 2]', self.SCHEMA) + + def test_rejects_a_required_key_of_the_wrong_type(self): + with self.assertRaises(client.SchemaViolation): + client.parse('{"records": "not a list"}', self.SCHEMA) + + +class BackoffTests(unittest.TestCase): + def test_waits_longer_after_each_attempt(self): + delays = [client.backoff(n) for n in range(4)] + self.assertEqual(delays, sorted(delays)) + self.assertLess(delays[0], delays[-1]) + + def test_honours_a_retry_after_header(self): + self.assertEqual(client.backoff(0, retry_after="12"), 12.0) + + def test_ignores_an_unparseable_retry_after(self): + self.assertEqual(client.backoff(0, retry_after="whenever"), client.backoff(0)) + + def test_caps_the_wait(self): + self.assertLessEqual(client.backoff(50), client.MAX_BACKOFF) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_construct_corpus.py b/tests/test_construct_corpus.py new file mode 100644 index 0000000..84df02d --- /dev/null +++ b/tests/test_construct_corpus.py @@ -0,0 +1,73 @@ +"""Replay against a real document corpus. + +Skipped unless DOCKET_CORPUS names a checkout holding context/**/*.md. The +numbers in docs/superpowers/specs/2026-09-13-docket-construct-design.md were +measured this way, and this keeps them checkable. +""" + +import os +import random +import re +import unittest +from pathlib import Path + +from docket.construct import extract + +CORPUS = os.environ.get("DOCKET_CORPUS", "") +_root = Path(CORPUS) if CORPUS else None +_have = bool(_root and (_root / "context").is_dir()) + + +def _as_a_model_would(line: str) -> str: + """A source line as extraction returns it: emphasis gone, respaced.""" + return re.sub(r"\s+", " ", re.sub(r"[*_`]+", "", line)).strip() + + +@unittest.skipUnless(_have, "set DOCKET_CORPUS to a checkout with context/*.md") +class CorpusTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.files = sorted(_root.rglob("context/**/*.md")) + cls.git = extract.git_dates(_root) + + def test_one_git_call_covers_the_whole_tree(self): + self.assertGreater(len(self.git), len(self.files)) + + def test_nearly_every_document_resolves_a_date(self): + dated = sum( + 1 for f in self.files + if extract.resolve_date(str(f.relative_to(_root)), + f.read_text(errors="replace"), self.git) + ) + # Measured at 96.5% over 372 documents. A drop means a date convention + # changed, or the head window stopped covering where dates are written. + self.assertGreaterEqual(dated / len(self.files), 0.90) + + def test_anchors_survive_the_formatting_a_model_strips(self): + random.seed(20260914) + hits = total = 0 + for f in random.sample(self.files, min(60, len(self.files))): + text = f.read_text(errors="replace") + lines = [l for l in text.splitlines() if len(l.strip()) > 30] + for line in random.sample(lines, min(3, len(lines))): + total += 1 + hits += bool(extract.anchor_line(_as_a_model_would(line), text)) + self.assertGreater(total, 100) + # The spec's step-2 floor. Measured at 100% for these two perturbations; + # real model output also paraphrases, which this cannot simulate. + self.assertGreaterEqual(hits / total, 0.95) + + def test_an_anchor_does_not_match_a_different_document(self): + random.seed(20260914) + for _ in range(20): + a, b = random.sample(self.files, 2) + lines = [l for l in a.read_text(errors="replace").splitlines() + if len(l.strip()) > 40] + if not lines: + continue + probe = _as_a_model_would(random.choice(lines)) + self.assertIsNone(extract.anchor_line(probe, b.read_text(errors="replace"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_construct_extract.py b/tests/test_construct_extract.py new file mode 100644 index 0000000..6cebd2b --- /dev/null +++ b/tests/test_construct_extract.py @@ -0,0 +1,171 @@ +"""Anchor matching and date resolution. No model, no network.""" + +import subprocess +import tempfile +import unittest +from pathlib import Path + +from docket.construct import extract + + +class AnchorMatchTests(unittest.TestCase): + SOURCE = "\n".join([ + "# Anti-Context-Pollution Architecture Decisions", + "", + "Date: 2026-06-18", + "", + "### 1. SAVER Audit-Repair: Tiered by Layer", + "", + "**Decision:** Option B - tiered checking by layer.", + "", + "**Rationale:** State contamination requires sanitization first.", + ]) + + def test_finds_a_line_quoted_verbatim(self): + self.assertEqual( + extract.anchor_line("### 1. SAVER Audit-Repair: Tiered by Layer", self.SOURCE), + 5) + + def test_finds_a_line_whose_emphasis_the_model_dropped(self): + # The spike's entire batch-one miss: source has the bold markers, the + # model's anchor does not. + self.assertEqual( + extract.anchor_line("Decision: Option B - tiered checking by layer.", self.SOURCE), + 7) + + def test_finds_a_line_the_model_respaced(self): + self.assertEqual( + extract.anchor_line("Decision: Option B - tiered checking by layer.", self.SOURCE), + 7) + + def test_returns_none_when_the_anchor_is_not_in_the_source(self): + self.assertIsNone(extract.anchor_line("Decision: use JWT everywhere", self.SOURCE)) + + def test_returns_the_first_match_when_a_line_repeats(self): + source = "same line\nother\nsame line" + self.assertEqual(extract.anchor_line("same line", source), 1) + + def test_an_empty_anchor_matches_nothing(self): + self.assertIsNone(extract.anchor_line("", self.SOURCE)) + + def test_does_not_match_a_line_that_merely_contains_the_anchor(self): + # A substring match would let a three-word anchor claim any paragraph. + source = "The decision to use JWT was taken after review" + self.assertIsNone(extract.anchor_line("use JWT", source)) + + +class MatchRateTests(unittest.TestCase): + def test_reports_matched_over_total(self): + source = "alpha\nbeta" + proposals = [{"anchor": "alpha"}, {"anchor": "beta"}, {"anchor": "gamma"}] + self.assertEqual(extract.match_rate(proposals, source), (2, 3)) + + def test_an_empty_set_reports_zero_of_zero(self): + self.assertEqual(extract.match_rate([], "alpha"), (0, 0)) + + +class DateFromTextTests(unittest.TestCase): + def test_reads_a_date_line_from_the_document_head(self): + text = "# Title\n\nDate: 2026-06-18\nStatus: Decided\n" + self.assertEqual(extract.date_from_text(text), "2026-06-18") + + def test_reads_a_bolded_date_label(self): + text = "# Title\n\n**Date:** 2026-06-18\n" + self.assertEqual(extract.date_from_text(text), "2026-06-18") + + def test_ignores_a_date_line_far_below_the_head(self): + # A date deep in the body is a fact about the subject, not the document. + text = "# Title\n" + "\n" * 40 + "Date: 2026-06-18\n" + self.assertIsNone(extract.date_from_text(text)) + + def test_returns_none_when_there_is_no_date_line(self): + self.assertIsNone(extract.date_from_text("# Title\n\nSome prose.\n")) + + +class DateFromNameTests(unittest.TestCase): + def test_reads_a_leading_date(self): + self.assertEqual(extract.date_from_name("2026-06-18-coherence-layer.md"), + "2026-06-18") + + def test_reads_a_date_sitting_mid_name(self): + # positioning-2026-05-24.md in the real corpus; a leading-date parse + # misses it entirely. + self.assertEqual(extract.date_from_name("positioning-2026-05-24.md"), + "2026-05-24") + + def test_returns_none_for_an_undated_name(self): + self.assertIsNone(extract.date_from_name("silo-portability.md")) + + def test_rejects_an_impossible_date(self): + self.assertIsNone(extract.date_from_name("2026-13-45-nonsense.md")) + + +class GitDatesTests(unittest.TestCase): + def repo(self, tmp): + root = Path(tmp) + run = lambda *a: subprocess.run(["git", "-C", str(root), *a], + capture_output=True, check=True) + run("init", "-q") + run("config", "user.email", "t@example.com") + run("config", "user.name", "t") + (root / "a.md").write_text("alpha\n") + (root / "b.md").write_text("beta\n") + run("add", "-A") + run("commit", "-q", "-m", "first", "--date", "2026-03-04T10:00:00+00:00") + return root + + def test_maps_every_tracked_path_to_its_last_commit_date(self): + with tempfile.TemporaryDirectory() as tmp: + root = self.repo(tmp) + dates = extract.git_dates(root) + self.assertEqual(dates["a.md"], "2026-03-04") + self.assertEqual(dates["b.md"], "2026-03-04") + + def test_outside_a_repository_it_reports_nothing(self): + with tempfile.TemporaryDirectory() as tmp: + self.assertEqual(extract.git_dates(Path(tmp)), {}) + + def test_one_call_covers_the_whole_tree(self): + # 133 of the real corpus's 372 documents have no date anywhere but git. + # A subprocess per file would make the resolution pass the slow part. + with tempfile.TemporaryDirectory() as tmp: + root = self.repo(tmp) + calls = [] + real = subprocess.run + + def counting(*a, **k): + calls.append(a) + return real(*a, **k) + + extract.subprocess.run = counting + try: + extract.git_dates(root) + finally: + extract.subprocess.run = real + self.assertEqual(len(calls), 1) + + +class ResolveDateTests(unittest.TestCase): + def test_a_date_line_wins_over_the_filename(self): + self.assertEqual( + extract.resolve_date("2026-01-01-thing.md", "Date: 2026-06-18\n", {}), + "2026-06-18") + + def test_the_filename_wins_over_git(self): + self.assertEqual( + extract.resolve_date("2026-01-01-thing.md", "no date here", + {"2026-01-01-thing.md": "2026-09-09"}), + "2026-01-01") + + def test_git_answers_when_nothing_else_does(self): + self.assertEqual( + extract.resolve_date("thing.md", "no date here", {"thing.md": "2026-09-09"}), + "2026-09-09") + + def test_a_document_with_no_date_anywhere_resolves_to_none(self): + # Pass 2 proposes no supersession edge for such a record. + self.assertIsNone(extract.resolve_date("thing.md", "no date here", {})) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_construct_link.py b/tests/test_construct_link.py new file mode 100644 index 0000000..b5789bb --- /dev/null +++ b/tests/test_construct_link.py @@ -0,0 +1,306 @@ +"""Pass 2: the payload a linker sees, and the edges it is allowed to propose.""" + +import unittest + +from docket.construct import link, schema + + +def prop(anchor, kind="decision", choice="yes", text="Question?", date="2026-06-18", + path="a.md", rationale="because", scope=None): + return schema.proposal(kind=kind, text=text, choice=choice, anchor=anchor, + rationale=rationale, scope=scope or [], + source={"path": path, "date": date}) + + +class PayloadTests(unittest.TestCase): + def test_labels_each_record_so_a_model_can_reference_it(self): + rows = link.payload([prop("one"), prop("two")]) + self.assertEqual([r["id"] for r in rows], ["p1", "p2"]) + + def test_carries_only_what_linking_needs(self): + rows = link.payload([prop("one")]) + self.assertEqual(set(rows[0]), {"id", "kind", "text", "choice", "path", "date"}) + + def test_omits_the_anchor_and_rationale_to_keep_the_call_small(self): + # The whole point of sending proposals instead of documents: the spike's + # 61 records are 2.8k tokens of metadata against 15k words of source. + rows = link.payload([prop("one")]) + self.assertNotIn("anchor", rows[0]) + self.assertNotIn("rationale", rows[0]) + + def test_a_label_maps_back_to_the_record_key(self): + items = [prop("one"), prop("two")] + labels = link.labels(items) + self.assertEqual(labels["p2"], items[1]["key"]) + + +class ReferenceTests(unittest.TestCase): + def test_keeps_an_edge_between_known_records(self): + items = [prop("one"), prop("two")] + edges, dropped = link.validate([{"kind": "supports", "from": "p2", "to": "p1"}], items) + self.assertEqual(len(edges), 1) + self.assertEqual(dropped, []) + + def test_drops_an_edge_naming_a_record_that_does_not_exist(self): + items = [prop("one")] + edges, dropped = link.validate([{"kind": "supports", "from": "p1", "to": "p9"}], items) + self.assertEqual(edges, []) + self.assertIn("p9", dropped[0]) + + def test_drops_an_edge_from_a_record_to_itself(self): + items = [prop("one")] + edges, dropped = link.validate([{"kind": "supports", "from": "p1", "to": "p1"}], items) + self.assertEqual(edges, []) + self.assertTrue(dropped) + + def test_drops_an_edge_of_an_unknown_kind(self): + items = [prop("one"), prop("two")] + edges, dropped = link.validate([{"kind": "resembles", "from": "p1", "to": "p2"}], items) + self.assertEqual(edges, []) + self.assertIn("resembles", dropped[0]) + + +class SupersedesTests(unittest.TestCase): + def test_keeps_a_later_record_superseding_an_earlier_one(self): + items = [prop("old", date="2026-01-01"), prop("new", date="2026-06-01")] + edges, dropped = link.validate([{"kind": "supersedes", "from": "p2", "to": "p1"}], items) + self.assertEqual(len(edges), 1) + + def test_drops_an_earlier_record_superseding_a_later_one(self): + items = [prop("old", date="2026-01-01"), prop("new", date="2026-06-01")] + edges, dropped = link.validate([{"kind": "supersedes", "from": "p1", "to": "p2"}], items) + self.assertEqual(edges, []) + self.assertIn("earlier", dropped[0]) + + def test_drops_supersession_across_two_different_kinds(self): + items = [prop("c", kind="claim", choice=""), prop("d", kind="decision")] + edges, dropped = link.validate([{"kind": "supersedes", "from": "p2", "to": "p1"}], items) + self.assertEqual(edges, []) + self.assertIn("kind", dropped[0]) + + def test_drops_supersession_when_either_date_is_missing(self): + # 13 of the corpus's 372 documents resolve no date at all, and + # supersession is the one relation that rests entirely on dates. + items = [prop("old", date=None), prop("new", date="2026-06-01")] + edges, dropped = link.validate([{"kind": "supersedes", "from": "p2", "to": "p1"}], items) + self.assertEqual(edges, []) + self.assertIn("date", dropped[0]) + + def test_drops_supersession_between_records_sharing_a_date(self): + items = [prop("a", date="2026-06-18"), prop("b", date="2026-06-18")] + edges, dropped = link.validate([{"kind": "supersedes", "from": "p2", "to": "p1"}], items) + self.assertEqual(edges, []) + + +class LedgerRuleTests(unittest.TestCase): + """Rules docket/ledger.py enforces on append, mirrored here. + + An edge that passes validation and then aborts the append is worse than one + dropped now: acceptance has no transaction, so the abort leaves records + written and the stage untouched. + """ + + def test_drops_support_pointing_at_a_question(self): + items = [prop("q", kind="question", choice=""), prop("d")] + edges, dropped = link.validate( + [{"kind": "supports", "from": "p2", "to": "p1"}], items) + self.assertEqual(edges, []) + self.assertIn("question", dropped[0]) + + def test_keeps_support_pointing_at_a_claim(self): + items = [prop("c", kind="claim", choice=""), prop("d")] + edges, _ = link.validate( + [{"kind": "supports", "from": "p2", "to": "p1"}], items) + self.assertEqual(len(edges), 1) + + def test_drops_a_second_record_superseding_the_same_target(self): + # The ledger retires a target once; the second append is refused. + items = [prop("old", date="2026-01-01"), + prop("mid", date="2026-03-01"), + prop("new", date="2026-06-01")] + edges, dropped = link.validate([ + {"kind": "supersedes", "from": "p2", "to": "p1"}, + {"kind": "supersedes", "from": "p3", "to": "p1"}, + ], items) + self.assertEqual(len(edges), 1) + self.assertTrue(any("already" in d for d in dropped)) + + +class SupportsAcyclicTests(unittest.TestCase): + def test_keeps_a_chain(self): + items = [prop("a"), prop("b"), prop("c")] + edges, dropped = link.validate([ + {"kind": "supports", "from": "p2", "to": "p1"}, + {"kind": "supports", "from": "p3", "to": "p2"}, + ], items) + self.assertEqual(len(edges), 2) + self.assertEqual(dropped, []) + + def test_drops_the_edge_that_closes_a_two_record_cycle(self): + items = [prop("a"), prop("b")] + edges, dropped = link.validate([ + {"kind": "supports", "from": "p2", "to": "p1"}, + {"kind": "supports", "from": "p1", "to": "p2"}, + ], items) + self.assertEqual(len(edges), 1) + self.assertIn("cycle", dropped[0]) + + def test_drops_the_edge_that_closes_a_longer_cycle(self): + items = [prop("a"), prop("b"), prop("c")] + edges, dropped = link.validate([ + {"kind": "supports", "from": "p2", "to": "p1"}, + {"kind": "supports", "from": "p3", "to": "p2"}, + {"kind": "supports", "from": "p1", "to": "p3"}, + ], items) + self.assertEqual(len(edges), 2) + self.assertTrue(any("cycle" in d for d in dropped)) + + def test_a_cycle_through_supersedes_does_not_block_a_supports_edge(self): + # Only supports has to stay acyclic; the two relations are separate. + items = [prop("a", date="2026-01-01"), prop("b", date="2026-06-01")] + edges, _ = link.validate([ + {"kind": "supersedes", "from": "p2", "to": "p1"}, + {"kind": "supports", "from": "p2", "to": "p1"}, + ], items) + self.assertEqual(len(edges), 2) + + +class ContradictionTests(unittest.TestCase): + def pair(self): + return [prop("a", kind="claim", choice="", text="Write-gating adds 50-200ms"), + prop("b", kind="claim", choice="", text="Write latency target is under 50ms", + path="b.md")] + + def test_keeps_a_contradiction_between_two_records(self): + edges, dropped = link.validate( + [{"kind": "contradicts", "from": "p1", "to": "p2"}], self.pair()) + self.assertEqual(len(edges), 1) + self.assertEqual(dropped, []) + + def test_a_contradiction_needs_no_date_and_no_shared_kind(self): + items = [prop("a", kind="claim", choice="", date=None), + prop("b", kind="decision", path="b.md")] + edges, dropped = link.validate( + [{"kind": "contradicts", "from": "p1", "to": "p2"}], items) + self.assertEqual(len(edges), 1) + + def test_a_contradiction_becomes_a_proposed_question(self): + # The spec's rule: a contradiction the linker cannot resolve is a + # question naming both records, never a silent supersedes. + items = self.pair() + edges = [{"kind": "contradicts", "from": "p1", "to": "p2"}] + asked = link.questions(edges, items) + self.assertEqual(len(asked), 1) + self.assertEqual(asked[0]["kind"], "question") + + def test_the_question_names_both_records(self): + items = self.pair() + asked = link.questions([{"kind": "contradicts", "from": "p1", "to": "p2"}], items) + text = asked[0]["text"] + self.assertIn("50-200ms", text) + self.assertIn("under 50ms", text) + + def test_the_question_anchors_on_one_of_the_two_sources(self): + # A synthesized record still needs a line a reviewer can open. + items = self.pair() + asked = link.questions([{"kind": "contradicts", "from": "p1", "to": "p2"}], items) + self.assertIn(asked[0]["source"]["path"], {"a.md", "b.md"}) + self.assertTrue(asked[0]["anchor"]) + + def test_the_same_contradiction_asks_the_same_question_twice_over(self): + items = self.pair() + edge = [{"kind": "contradicts", "from": "p1", "to": "p2"}] + self.assertEqual(link.questions(edge, items)[0]["key"], + link.questions(edge, items)[0]["key"]) + + def test_the_question_keys_apart_from_the_record_it_borrowed_from(self): + # It takes that record's anchor and source. Keying the same would make + # acceptance drop one of the two as a duplicate, silently. + items = self.pair() + asked = link.questions([{"kind": "contradicts", "from": "p1", "to": "p2"}], items) + self.assertNotEqual(asked[0]["key"], items[0]["key"]) + self.assertNotEqual(asked[0]["key"], items[1]["key"]) + + def test_a_contradiction_writes_no_relation_onto_either_record(self): + items = self.pair() + out = link.apply([{"kind": "contradicts", "from": "p1", "to": "p2"}], items) + self.assertEqual(out[0]["supports"], []) + self.assertEqual(out[0]["supersedes"], []) + + def test_no_contradictions_asks_nothing(self): + self.assertEqual(link.questions([], self.pair()), []) + + +class BatchTests(unittest.TestCase): + def items(self, n, per_doc=7): + # Seven per document against a size of 120 divides unevenly, so a naive + # slicer splits a document and fails. Five per document would divide + # cleanly and let a slicer pass. + return [prop(f"anchor {i}", path=f"doc{i // per_doc}.md") for i in range(n)] + + def test_a_small_set_is_one_batch(self): + self.assertEqual(len(link.batches(self.items(10), size=120)), 1) + + def test_a_large_set_splits(self): + self.assertEqual(len(link.batches(self.items(300), size=120)), 3) + + def test_every_record_lands_in_exactly_one_batch(self): + items = self.items(300) + keys = [p["key"] for batch in link.batches(items, size=120) for p in batch] + self.assertEqual(sorted(keys), sorted(p["key"] for p in items)) + + def test_a_document_is_not_split_across_batches_when_it_fits(self): + # Records from one document are the likeliest to relate, so splitting a + # document costs the edges the linker would most reliably find. + batches = link.batches(self.items(300), size=120) + self.assertGreater(len(batches), 1) + where: dict[str, int] = {} + for index, batch in enumerate(batches): + for item in batch: + path = item["source"]["path"] + self.assertEqual(where.setdefault(path, index), index, + f"{path} spans more than one batch") + + def test_batching_is_off_for_a_size_of_zero(self): + self.assertEqual(len(link.batches(self.items(300), size=0)), 1) + + +class ApplyTests(unittest.TestCase): + def test_writes_supports_onto_the_supporting_record(self): + items = [prop("a"), prop("b")] + edges = [{"kind": "supports", "from": "p2", "to": "p1"}] + out = link.apply(edges, items) + self.assertEqual(out[1]["supports"], [[items[0]["key"]]]) + + def test_writes_supersedes_onto_the_later_record(self): + items = [prop("a", date="2026-01-01"), prop("b", date="2026-06-01")] + edges = [{"kind": "supersedes", "from": "p2", "to": "p1"}] + out = link.apply(edges, items) + self.assertEqual(out[1]["supersedes"], [items[0]["key"]]) + + def test_leaves_an_unreferenced_record_alone(self): + items = [prop("a"), prop("b")] + out = link.apply([], items) + self.assertEqual(out[0].get("supports", []), []) + + def test_keeps_several_grounds_as_separate_alternatives(self): + # docket/ledger.py reads supports as a list of conjunctive sets, so one + # set means "all of these are required". A linker sees two independent + # grounds and cannot tell that; joining them claims more than it saw. + items = [prop("a"), prop("b"), prop("c")] + edges = [{"kind": "supports", "from": "p3", "to": "p1"}, + {"kind": "supports", "from": "p3", "to": "p2"}] + out = link.apply(edges, items) + self.assertEqual(out[2]["supports"], + [[items[0]["key"]], [items[1]["key"]]]) + + def test_does_not_mutate_the_records_it_was_given(self): + items = [prop("a", date="2026-01-01"), prop("b", date="2026-06-01")] + items[1]["supersedes"] = [] + before = items[1]["supersedes"] + link.apply([{"kind": "supersedes", "from": "p2", "to": "p1"}], items) + self.assertEqual(before, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_construct_run.py b/tests/test_construct_run.py new file mode 100644 index 0000000..b29693d --- /dev/null +++ b/tests/test_construct_run.py @@ -0,0 +1,391 @@ +"""The two-pass driver, with the provider call injected.""" + +import subprocess +import tempfile +import unittest +from pathlib import Path + +from docket.construct import run + + +class SchemaShapeTests(unittest.TestCase): + """Both schemas travel with `"strict": True`. + + Strict mode requires every property listed in `required` and + `additionalProperties: false`. A schema that misses either is refused by the + endpoint, and no fake caller would ever notice. + """ + + def objects(self, node): + if isinstance(node, dict): + if node.get("type") == "object": + yield node + for value in node.values(): + yield from self.objects(value) + elif isinstance(node, list): + for value in node: + yield from self.objects(value) + + def test_every_property_is_required(self): + for schema in (run.EXTRACT_SCHEMA, run.LINK_SCHEMA): + for node in self.objects(schema): + self.assertEqual(set(node.get("properties", {})), + set(node.get("required", [])), node) + + def test_no_object_admits_extra_properties(self): + for schema in (run.EXTRACT_SCHEMA, run.LINK_SCHEMA): + for node in self.objects(schema): + self.assertIs(node.get("additionalProperties"), False, node) + + +class FakeCaller: + """Stands in for the provider. Records what it was asked.""" + + def __init__(self, extract_reply=None, link_reply=None): + self.extract_reply = extract_reply or {"records": []} + self.link_reply = link_reply or {"edges": []} + self.prompts = [] + + def __call__(self, prompt, schema): + self.prompts.append(prompt) + # Identity, never a property name: dispatching on the schema's contents + # would couple every test to the schema's internals. + if schema is run.LINK_SCHEMA: + return self.link_reply + return self.extract_reply + + +def doc(root, name, body): + path = Path(root) / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body) + return path + + +class PathSpellingTests(unittest.TestCase): + """A record's key and its git date both hinge on the path's spelling. + + An absolute path argument would otherwise restage every record as new, and + make every git-date lookup miss, which silently disables supersession. + """ + + def test_an_absolute_path_records_the_repository_relative_one(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + (root / "docs").mkdir() + doc(root, "docs/one.md", "# one\n\nClaim: a\n") + caller = FakeCaller({"records": [ + {"kind": "claim", "text": "A", "choice": "", "anchor": "Claim: a", + "rationale": "", "scope": []}]}) + got, _ = run.two_pass([str(root / "docs")], caller=caller, root=root) + self.assertEqual(got[0]["source"]["path"], "docs/one.md") + + def test_derives_the_root_from_the_documents_own_repository(self): + # Reading another project's history is the main case, so the root cannot + # be the working directory. Without this, paths stay absolute and a + # different spelling on the next run restages every record. + with tempfile.TemporaryDirectory() as tmp: + other = Path(tmp).resolve() / "other" + (other / "docs").mkdir(parents=True) + subprocess.run(["git", "-C", str(other), "init", "-q"], check=True) + doc(other, "docs/one.md", "# one\n\nClaim: a\n") + caller = FakeCaller({"records": [ + {"kind": "claim", "text": "A", "choice": "", "anchor": "Claim: a", + "rationale": "", "scope": []}]}) + got, _ = run.two_pass([str(other / "docs")], caller=caller) + self.assertEqual(got[0]["source"]["path"], "docs/one.md") + + def test_two_spellings_of_one_document_give_one_key(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + doc(root, "one.md", "# one\n\nClaim: a\n") + reply = {"records": [ + {"kind": "claim", "text": "A", "choice": "", "anchor": "Claim: a", + "rationale": "", "scope": []}]} + absolute, _ = run.two_pass([str(root / "one.md")], + caller=FakeCaller(reply), root=root) + relative, _ = run.two_pass([str(root / "./one.md")], + caller=FakeCaller(reply), root=root) + self.assertEqual(absolute[0]["key"], relative[0]["key"]) + + +class DiscoveryTests(unittest.TestCase): + def test_reads_every_markdown_file_under_a_directory(self): + with tempfile.TemporaryDirectory() as tmp: + doc(tmp, "a/one.md", "# one\n") + doc(tmp, "a/two.md", "# two\n") + doc(tmp, "a/skip.txt", "not markdown") + self.assertEqual(len(run.documents([str(Path(tmp) / "a")])), 2) + + def test_accepts_a_single_file(self): + with tempfile.TemporaryDirectory() as tmp: + path = doc(tmp, "one.md", "# one\n") + self.assertEqual(run.documents([str(path)]), [path]) + + def test_skips_a_path_that_does_not_exist(self): + self.assertEqual(run.documents(["/nowhere/at/all"]), []) + + def test_returns_a_stable_order(self): + with tempfile.TemporaryDirectory() as tmp: + doc(tmp, "b.md", "x") + doc(tmp, "a.md", "x") + names = [p.name for p in run.documents([tmp])] + self.assertEqual(names, sorted(names)) + + +class ExtractPassTests(unittest.TestCase): + SOURCE = "# Title\n\nDate: 2026-06-18\n\n**Decision:** keep the ledger local\n" + + def reply(self, **over): + record = {"kind": "decision", "text": "Where does the ledger live?", + "choice": "Local", "anchor": "Decision: keep the ledger local", + "rationale": "because", "scope": ["docket/**"]} + record.update(over) + return {"records": [record]} + + def one_doc(self, tmp, body=None): + return doc(tmp, "context/d.md", body or self.SOURCE) + + def test_stages_a_record_the_model_returned(self): + with tempfile.TemporaryDirectory() as tmp: + self.one_doc(tmp) + caller = FakeCaller(self.reply()) + got, _ = run.two_pass([tmp], caller=caller) + self.assertEqual(len(got), 1) + self.assertEqual(got[0]["text"], "Where does the ledger live?") + + def test_resolves_the_document_date_locally(self): + with tempfile.TemporaryDirectory() as tmp: + self.one_doc(tmp) + got, _ = run.two_pass([tmp], caller=FakeCaller(self.reply())) + self.assertEqual(got[0]["source"]["date"], "2026-06-18") + + def test_records_the_line_the_anchor_matched(self): + with tempfile.TemporaryDirectory() as tmp: + self.one_doc(tmp) + got, _ = run.two_pass([tmp], caller=FakeCaller(self.reply())) + self.assertEqual(got[0]["line"], 5) + + def test_drops_a_record_whose_anchor_is_nowhere_in_the_source(self): + with tempfile.TemporaryDirectory() as tmp: + self.one_doc(tmp) + caller = FakeCaller(self.reply(anchor="Decision: something invented")) + got, report = run.two_pass([tmp], caller=caller) + self.assertEqual(got, []) + self.assertTrue(any("anchor" in line for line in report)) + + def test_drops_a_scope_entry_that_does_not_address_a_file(self): + with tempfile.TemporaryDirectory() as tmp: + self.one_doc(tmp) + caller = FakeCaller(self.reply(scope=["a long prose description here"])) + got, _ = run.two_pass([tmp], caller=caller) + self.assertEqual(got[0]["scope"], []) + + def test_drops_a_record_the_schema_rejects(self): + with tempfile.TemporaryDirectory() as tmp: + self.one_doc(tmp) + caller = FakeCaller(self.reply(kind="opinion")) + got, report = run.two_pass([tmp], caller=caller) + self.assertEqual(got, []) + self.assertTrue(report) + + def test_reports_the_anchor_match_rate(self): + with tempfile.TemporaryDirectory() as tmp: + self.one_doc(tmp) + _, report = run.two_pass([tmp], caller=FakeCaller(self.reply())) + self.assertTrue(any("anchors" in line for line in report)) + + def test_sends_the_document_body_to_the_model(self): + with tempfile.TemporaryDirectory() as tmp: + self.one_doc(tmp) + caller = FakeCaller(self.reply()) + run.two_pass([tmp], caller=caller) + self.assertIn("keep the ledger local", caller.prompts[0]) + + +class LinkPassTests(unittest.TestCase): + SOURCE_A = "# A\n\nDate: 2026-01-01\n\nClaim: state must be sanitized\n" + SOURCE_B = "# B\n\nDate: 2026-06-01\n\nDecision: tier the audit by layer\n" + + def replies(self, edges): + return FakeCaller(link_reply={"edges": edges}) + + def build(self, tmp, caller): + doc(tmp, "a.md", self.SOURCE_A) + doc(tmp, "b.md", self.SOURCE_B) + + def per_doc(prompt, schema): + if schema is run.LINK_SCHEMA: + return caller.link_reply + if "sanitized" in prompt: + return {"records": [{"kind": "claim", "text": "State must be sanitized", + "choice": "", "anchor": "Claim: state must be sanitized", + "rationale": "", "scope": []}]} + return {"records": [{"kind": "decision", "text": "How is audit checked?", + "choice": "Tiered", "anchor": "Decision: tier the audit by layer", + "rationale": "", "scope": []}]} + + return run.two_pass([tmp], caller=per_doc) + + def test_applies_a_support_edge_across_two_documents(self): + # The whole reason pass 2 exists: a per-document call cannot see this. + with tempfile.TemporaryDirectory() as tmp: + got, _ = self.build(tmp, self.replies( + [{"kind": "supports", "from": "p2", "to": "p1"}])) + by_kind = {p["kind"]: p for p in got} + self.assertEqual(by_kind["decision"]["supports"], + [[by_kind["claim"]["key"]]]) + + def test_drops_an_edge_the_local_rules_refuse(self): + with tempfile.TemporaryDirectory() as tmp: + got, report = self.build(tmp, self.replies( + [{"kind": "supersedes", "from": "p2", "to": "p1"}])) + self.assertTrue(any("supersedes" in line for line in report)) + self.assertTrue(all(not p["supersedes"] for p in got)) + + def test_skips_the_link_pass_for_a_single_record(self): + with tempfile.TemporaryDirectory() as tmp: + doc(tmp, "a.md", self.SOURCE_A) + caller = FakeCaller({"records": [ + {"kind": "claim", "text": "State must be sanitized", "choice": "", + "anchor": "Claim: state must be sanitized", "rationale": "", "scope": []}]}) + run.two_pass([tmp], caller=caller) + # One extraction prompt, no linking prompt: there is nothing to link. + self.assertEqual(len(caller.prompts), 1) + + +class LinkBatchTests(unittest.TestCase): + def test_links_in_batches_and_reports_how_many(self): + with tempfile.TemporaryDirectory() as tmp: + for n in range(6): + doc(tmp, f"d{n}.md", f"# d{n}\n\nClaim: number {n}\n") + calls = [] + + def caller(prompt, schema): + if schema is run.LINK_SCHEMA: + calls.append(prompt) + return {"edges": []} + n = prompt.split("Claim: number ")[1][0] + return {"records": [{"kind": "claim", "text": f"Number {n}", "choice": "", + "anchor": f"Claim: number {n}", "rationale": "", + "scope": []}]} + + _, report = run.two_pass([tmp], caller=caller, batch=2) + self.assertEqual(len(calls), 3) + self.assertTrue(any("3 batches" in line for line in report)) + + def test_a_contradiction_is_staged_as_a_question(self): + with tempfile.TemporaryDirectory() as tmp: + doc(tmp, "a.md", "# a\n\nClaim: latency is 200ms\n") + doc(tmp, "b.md", "# b\n\nClaim: latency is under 50ms\n") + + def caller(prompt, schema): + if schema is run.LINK_SCHEMA: + return {"edges": [{"kind": "contradicts", "from": "p1", "to": "p2"}]} + text = "latency is 200ms" if "200ms" in prompt else "latency is under 50ms" + return {"records": [{"kind": "claim", "text": text.capitalize(), "choice": "", + "anchor": f"Claim: {text}", "rationale": "", "scope": []}]} + + got, report = run.two_pass([tmp], caller=caller) + questions = [p for p in got if p["kind"] == "question"] + self.assertEqual(len(questions), 1) + self.assertIn("200ms", questions[0]["text"]) + self.assertTrue(any("question" in line for line in report)) + + +class DryRunTests(unittest.TestCase): + def test_reads_no_document_and_calls_nothing(self): + with tempfile.TemporaryDirectory() as tmp: + doc(tmp, "a.md", "# a\n") + caller = FakeCaller() + got, report = run.two_pass([tmp], caller=caller, dry_run=True) + self.assertEqual(got, []) + self.assertEqual(caller.prompts, []) + self.assertTrue(any("1 document" in line for line in report)) + + +class RetryTests(unittest.TestCase): + """One call per document across a pool, so a 429 is expected. + + Without a retry the whole document's records are lost to one rate limit. + """ + + def source(self, tmp): + doc(tmp, "a.md", "# a\n\nClaim: one\n") + return {"records": [{"kind": "claim", "text": "One", "choice": "", + "anchor": "Claim: one", "rationale": "", "scope": []}]} + + def test_retries_a_rate_limited_document(self): + with tempfile.TemporaryDirectory() as tmp: + reply = self.source(tmp) + tries = {"n": 0} + + def limited(prompt, schema): + tries["n"] += 1 + if tries["n"] < 3: + raise RuntimeError("429 Too Many Requests") + return reply + + got, _ = run.two_pass([tmp], caller=limited, sleep=lambda s: None) + self.assertEqual(len(got), 1) + self.assertEqual(tries["n"], 3) + + def test_waits_longer_between_attempts(self): + with tempfile.TemporaryDirectory() as tmp: + self.source(tmp) + waits = [] + + def limited(prompt, schema): + raise RuntimeError("429 rate limited") + + run.two_pass([tmp], caller=limited, sleep=waits.append) + self.assertTrue(waits) + self.assertEqual(waits, sorted(waits)) + + def test_gives_up_after_the_last_attempt_and_reports_it(self): + with tempfile.TemporaryDirectory() as tmp: + self.source(tmp) + + def limited(prompt, schema): + raise RuntimeError("429 rate limited") + + got, report = run.two_pass([tmp], caller=limited, sleep=lambda s: None) + self.assertEqual(got, []) + self.assertTrue(any("429" in line for line in report)) + + def test_does_not_retry_an_error_that_is_not_a_rate_limit(self): + with tempfile.TemporaryDirectory() as tmp: + self.source(tmp) + tries = {"n": 0} + + def broken(prompt, schema): + tries["n"] += 1 + raise RuntimeError("400 malformed schema") + + run.two_pass([tmp], caller=broken, sleep=lambda s: None) + self.assertEqual(tries["n"], 1) + + +class FailureTests(unittest.TestCase): + def test_no_documents_is_an_error_naming_the_paths(self): + with self.assertRaises(run.RunError): + run.two_pass(["/nowhere"], caller=FakeCaller()) + + def test_one_document_failing_does_not_lose_the_others(self): + with tempfile.TemporaryDirectory() as tmp: + doc(tmp, "good.md", "# g\n\nClaim: one\n") + doc(tmp, "bad.md", "# b\n\nClaim: two\n") + + def flaky(prompt, schema): + if "two" in prompt: + raise RuntimeError("provider said no") + return {"records": [{"kind": "claim", "text": "One", "choice": "", + "anchor": "Claim: one", "rationale": "", "scope": []}]} + + got, report = run.two_pass([tmp], caller=flaky) + self.assertEqual(len(got), 1) + self.assertTrue(any("bad.md" in line for line in report)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_construct_schema.py b/tests/test_construct_schema.py new file mode 100644 index 0000000..82cf012 --- /dev/null +++ b/tests/test_construct_schema.py @@ -0,0 +1,177 @@ +"""Proposal shape, identity and local validation.""" + +import unittest + +from docket.construct import schema + + +class NormalizeAnchorTests(unittest.TestCase): + def test_strips_markdown_emphasis_around_a_label(self): + # The spike's whole batch-one anchor miss: the model emitted the label + # without the bold markers the source carries. + self.assertEqual( + schema.normalize_anchor("**Decision:** Option B - tiered checking"), + schema.normalize_anchor("Decision: Option B - tiered checking"), + ) + + def test_strips_inline_code_backticks(self): + self.assertEqual( + schema.normalize_anchor("use `build_context` for runs"), + schema.normalize_anchor("use build_context for runs"), + ) + + def test_keeps_underscores_because_identifiers_carry_them(self): + # The corpus has 62162 identifier underscores against 9 uses of + # _emphasis_. Stripping them merges set_timeout with settimeout, and a + # merged anchor silently collapses two records into one. + self.assertNotEqual( + schema.normalize_anchor("set_timeout(x) was removed"), + schema.normalize_anchor("settimeout(x) was removed"), + ) + + def test_keeps_a_dunder_distinct_from_the_bare_word(self): + self.assertNotEqual( + schema.normalize_anchor("`__init__` is generated"), + schema.normalize_anchor("init is generated"), + ) + + def test_collapses_runs_of_whitespace(self): + self.assertEqual( + schema.normalize_anchor("one two\n\tthree"), + schema.normalize_anchor("one two three"), + ) + + def test_keeps_distinct_sentences_distinct(self): + self.assertNotEqual( + schema.normalize_anchor("Store the ledger in the project"), + schema.normalize_anchor("Store the ledger globally"), + ) + + +class IdentityTests(unittest.TestCase): + def test_same_source_and_anchor_give_the_same_key(self): + a = schema.identity("context/decisions/auth.md", "**Decision:** use JWT") + b = schema.identity("context/decisions/auth.md", "Decision: use JWT") + self.assertEqual(a, b) + + def test_same_anchor_in_a_different_document_differs(self): + a = schema.identity("context/decisions/auth.md", "Decision: use JWT") + b = schema.identity("context/devlog/auth.md", "Decision: use JWT") + self.assertNotEqual(a, b) + + def test_the_separator_cannot_be_forged_from_path_text(self): + # A key built by plain concatenation would collide here. + a = schema.identity("a/b", "c") + b = schema.identity("a", "b/c") + self.assertNotEqual(a, b) + + +class ScopeValidationTests(unittest.TestCase): + def test_accepts_a_path_and_a_glob(self): + self.assertEqual(schema.invalid_scope(["docket/context.py", "lib/**"]), []) + + def test_rejects_the_prose_paragraph_the_spike_produced(self): + prose = ("This record covers the documents describing the coherence " + "layer and its storage decisions, " * 8) + "src/**" + self.assertTrue(len(prose) > 200) + self.assertEqual(schema.invalid_scope([prose]), [prose]) + + def test_rejects_an_entry_carrying_whitespace(self): + self.assertEqual(schema.invalid_scope(["src/a.py and src/b.py"]), + ["src/a.py and src/b.py"]) + + def test_rejects_an_empty_entry(self): + self.assertEqual(schema.invalid_scope([""]), [""]) + + def test_reports_every_bad_entry_not_just_the_first(self): + self.assertEqual(schema.invalid_scope(["", "ok/path.py", "a b"]), ["", "a b"]) + + def test_rejects_a_glob_that_matches_the_whole_tree(self): + # Scope decides which records a briefing surfaces. An entry matching + # everything makes the record surface in every briefing, which is worse + # than no scope at all. + self.assertEqual(schema.invalid_scope(["**"]), ["**"]) + self.assertEqual(schema.invalid_scope(["*"]), ["*"]) + self.assertEqual(schema.invalid_scope(["**/*"]), ["**/*"]) + + def test_rejects_an_absolute_path(self): + # Scope is matched against repository-relative paths, so an absolute one + # can never match, and it leaks a filesystem layout into the ledger. + self.assertEqual(schema.invalid_scope(["/etc/passwd"]), ["/etc/passwd"]) + + def test_rejects_a_path_climbing_out_of_the_repository(self): + self.assertEqual(schema.invalid_scope(["../../secrets.env"]), + ["../../secrets.env"]) + + def test_keeps_a_directory_glob(self): + self.assertEqual(schema.invalid_scope(["src/**", "docket/cli/*.py"]), []) + + +class IdentityDiscriminatorTests(unittest.TestCase): + def test_a_derived_record_keys_apart_from_the_one_it_borrowed_from(self): + # A contradiction question borrows a record's anchor and source so a + # reviewer has a line to open. Without a discriminator it keys the same, + # and acceptance drops one of the two as a duplicate. + plain = schema.identity("a.md", "Claim: x") + derived = schema.identity("a.md", "Claim: x", kind="question") + self.assertNotEqual(plain, derived) + + def test_the_discriminator_is_stable(self): + self.assertEqual(schema.identity("a.md", "Claim: x", kind="question"), + schema.identity("a.md", "Claim: x", kind="question")) + + +class ProposalTests(unittest.TestCase): + def make(self, **over): + fields = { + "kind": "decision", + "text": "Where does the ledger live?", + "choice": "In the project", + "anchor": "**Decision:** in the project", + "source": {"path": "context/decisions/ledger.md", "date": "2026-06-18"}, + } + fields.update(over) + return schema.proposal(**fields) + + def test_carries_the_identity_key_derived_from_source_and_anchor(self): + p = self.make() + self.assertEqual(p["key"], schema.identity(p["source"]["path"], p["anchor"])) + + def test_starts_staged(self): + self.assertEqual(self.make()["state"], "staged") + + def test_defaults_scope_and_confidence(self): + p = self.make() + self.assertEqual(p["scope"], []) + self.assertEqual(p["confidence"], "low") + + def test_rejects_a_kind_the_ledger_does_not_have(self): + with self.assertRaises(schema.SchemaError): + self.make(kind="opinion") + + def test_rejects_a_decision_with_no_choice(self): + with self.assertRaises(schema.SchemaError): + self.make(choice="") + + def test_a_question_needs_no_choice(self): + p = self.make(kind="question", choice="") + self.assertEqual(p["kind"], "question") + + def test_rejects_an_empty_anchor(self): + # The anchor is the identity key and the reviewer's way back to the + # source. A record without one cannot be resumed or checked. + with self.assertRaises(schema.SchemaError): + self.make(anchor="") + + def test_rejects_an_invalid_scope_entry(self): + with self.assertRaises(schema.SchemaError): + self.make(scope=["a b c"]) + + def test_a_null_date_is_allowed(self): + # Pass 1 cannot always resolve one, and pass 2 proposes no supersession + # edge for a record without it. + self.assertIsNone(self.make(source={"path": "a.md", "date": None})["source"]["date"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_construct_stage.py b/tests/test_construct_stage.py new file mode 100644 index 0000000..1ecf234 --- /dev/null +++ b/tests/test_construct_stage.py @@ -0,0 +1,146 @@ +"""Staging: the proposal file, resumption across runs, and review order.""" + +import tempfile +import unittest +from pathlib import Path + +from docket.construct import schema, stage + + +def prop(path="a.md", anchor="Decision: one", kind="decision", choice="yes", + scope=None, confidence="low", text="Question?"): + return schema.proposal(kind=kind, text=text, choice=choice, anchor=anchor, + scope=scope or [], confidence=confidence, + source={"path": path, "date": "2026-06-18"}) + + +class RoundTripTests(unittest.TestCase): + def test_writes_and_reads_back_the_same_proposals(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "proposed.jsonl" + items = [prop(anchor="Decision: one"), prop(anchor="Decision: two")] + stage.write(path, items) + self.assertEqual(stage.read(path), items) + + def test_reading_a_missing_file_gives_nothing(self): + with tempfile.TemporaryDirectory() as tmp: + self.assertEqual(stage.read(Path(tmp) / "absent.jsonl"), []) + + def test_a_unicode_anchor_survives_the_round_trip(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "proposed.jsonl" + item = prop(anchor="Décision : café — naïve") + stage.write(path, [item]) + self.assertEqual(stage.read(path)[0]["anchor"], "Décision : café — naïve") + + +class MergeTests(unittest.TestCase): + def test_a_rerun_keeps_an_accepted_record_accepted(self): + old = prop() + old["state"] = "accepted" + fresh = prop() + fresh["text"] = "The model worded it differently this run" + merged = stage.merge([old], [fresh]) + self.assertEqual(len(merged), 1) + self.assertEqual(merged[0]["state"], "accepted") + + def test_a_rerun_keeps_a_rejected_record_rejected(self): + old = prop() + old["state"] = "rejected" + merged = stage.merge([old], [prop()]) + self.assertEqual(merged[0]["state"], "rejected") + + def test_a_rerun_takes_the_fresh_wording_for_a_still_staged_record(self): + old = prop() + fresh = prop() + fresh["text"] = "Reworded" + merged = stage.merge([old], [fresh]) + self.assertEqual(merged[0]["text"], "Reworded") + + def test_an_edited_source_line_stages_the_record_again(self): + # The anchor is the key. Editing the source text the record was + # accepted against must put it back in front of a human. + old = prop(anchor="Decision: one") + old["state"] = "accepted" + merged = stage.merge([old], [prop(anchor="Decision: one, revised")]) + self.assertEqual(len(merged), 1) + self.assertEqual(merged[0]["state"], "staged") + + def test_a_record_no_longer_extracted_is_dropped(self): + old = prop(anchor="Decision: gone") + merged = stage.merge([old], [prop(anchor="Decision: here")]) + self.assertEqual([m["anchor"] for m in merged], ["Decision: here"]) + + def test_merging_into_an_empty_stage_stages_everything(self): + merged = stage.merge([], [prop(), prop(anchor="Decision: two")]) + self.assertEqual([m["state"] for m in merged], ["staged", "staged"]) + + +class ResolutionTests(unittest.TestCase): + def test_a_scope_matching_a_live_file_resolves(self): + self.assertTrue(stage.resolves(prop(scope=["docket/context.py"]), + {"docket/context.py", "README.md"})) + + def test_a_glob_resolves_against_a_live_file(self): + self.assertTrue(stage.resolves(prop(scope=["docket/**"]), + {"docket/context.py"})) + + def test_a_scope_matching_nothing_does_not_resolve(self): + self.assertFalse(stage.resolves(prop(scope=["db/labels_v2.py"]), + {"docket/context.py"})) + + def test_a_record_with_no_scope_does_not_resolve(self): + # 29 of the spike's 61 records carried no scope at all. Absence is not + # evidence the record is live. + self.assertFalse(stage.resolves(prop(scope=[]), {"docket/context.py"})) + + def test_one_resolving_entry_is_enough(self): + self.assertTrue(stage.resolves(prop(scope=["gone.py", "docket/context.py"]), + {"docket/context.py"})) + + +class ReviewOrderTests(unittest.TestCase): + def test_groups_by_source_document(self): + items = [prop(path="b.md", anchor="one"), prop(path="a.md", anchor="two"), + prop(path="b.md", anchor="three")] + groups = stage.review_groups(items, live={"x"}) + self.assertEqual([name for name, _ in groups], ["a.md", "b.md"]) + self.assertEqual(len(dict(groups)["b.md"]), 2) + + def test_sorts_high_confidence_first_inside_a_group(self): + items = [prop(anchor="low one", confidence="low"), + prop(anchor="high one", confidence="high"), + prop(anchor="medium one", confidence="medium")] + groups = stage.review_groups(items, live={"x"}) + self.assertEqual([p["anchor"] for p in dict(groups)["a.md"]], + ["high one", "medium one", "low one"]) + + def test_sinks_a_record_whose_scope_resolves_to_nothing(self): + live = {"docket/context.py"} + items = [prop(anchor="stale", confidence="high", scope=["gone.py"]), + prop(anchor="live", confidence="low", scope=["docket/context.py"])] + groups = stage.review_groups(items, live=live) + self.assertEqual([p["anchor"] for p in dict(groups)["a.md"]], ["live", "stale"]) + + def test_an_accepted_record_is_not_offered_for_review(self): + accepted = prop(anchor="done") + accepted["state"] = "accepted" + groups = stage.review_groups([accepted, prop(anchor="todo")], live={"x"}) + self.assertEqual([p["anchor"] for p in dict(groups)["a.md"]], ["todo"]) + + +class ResolutionRateTests(unittest.TestCase): + def test_reports_the_share_of_scoped_records_that_resolve(self): + live = {"docket/context.py"} + items = [prop(anchor="a", scope=["docket/context.py"]), + prop(anchor="b", scope=["gone.py"]), + prop(anchor="c", scope=[])] + # Two carry a scope; one of them resolves. + self.assertEqual(stage.resolution_rate(items, live), (1, 2)) + + def test_a_set_with_no_scoped_records_reports_zero_of_zero(self): + self.assertEqual(stage.resolution_rate([prop(scope=[])], {"x"}), (0, 0)) + + +if __name__ == "__main__": + unittest.main()