diff --git a/.docket/ledger.jsonl b/.docket/ledger.jsonl index 0fad561..f66d13e 100644 --- a/.docket/ledger.jsonl +++ b/.docket/ledger.jsonl @@ -138,3 +138,8 @@ {"schema":2,"kind":"claim","id":"c138","text":"Treating retirement as withdrawal, the any-surviving-set rule would retract 27 of 147 current reasoned records across eleven project ledgers, and most of those retirements restate or refine the ground rather than withdraw it.","state":"accepted","ts":"2026-10-02T13:56:44+00:00","author":"claude-code","session":"","branch":"main","scope":["docs/definitions.md","docs/north-star.md","docs/outcome-formalism.md"],"rationale":"About 22 of the 27 rest on a ground superseded by a restatement or refinement: docket d38 by d88, stoat d25 by d38, nimblefox d10 by d89. Three rest on c62, which was disputed and corrected by c105, the defeat the rule exists for. Two rest on unassessed claims. nimblefox c90 explicitly records that its dependents survived d69's replacement. Across 600 claims and decisions no record is rejected or revoked; grounds leave only by supersession or dispute.","supports":[["d137"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"docket list --superseded --json across docket, tessera, nimblefox-metaarch, hippocampus, relex-context, NovusEdge.github.io, locus-core, stoat, aiben, bot.sales, ocloak (2026-10-02)"}],"revisit":"When a ledger starts using rejected or revoked states, or when supersession gains a typed reason","cost_if_wrong":"Shipping automatic retraction on this reading would retract mostly sound records and train users to ignore it.","pinned":false} {"schema":2,"kind":"question","id":"q139","text":"When a supporting record is superseded, should its dependents lose that ground, keep it through the replacement, or be flagged for review?","state":"open","ts":"2026-10-02T13:56:52+00:00","author":"claude-code","session":"","branch":"main","scope":["docs/definitions.md","docs/north-star.md","docs/outcome-formalism.md"],"rationale":"Supersession records replacement, and the formal model only has withdrawal. Passing support to the replacement silently keeps dependents of a changed ground, as with nimblefox d69 to d97, where the substance changed. Withdrawing it retracts dependents of a mere restatement. Dispute is the one observed withdrawal.","supports":[["c138"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Phase 4 retraction picks one reading by default, and either default is wrong for a large share of records.","pinned":false} {"schema":2,"kind":"decision","id":"d140","text":"Each supersession carries a reason that decides what records citing the retired record keep.","state":"adopted","ts":"2026-10-02T14:48:54+00:00","author":"claude-code","session":"","branch":"main","scope":["docs/definitions.md","docs/outcome-formalism.md","experiments/lean-outcomes/**","docket/ledger.py"],"rationale":"Recorders already write the reason as free text in supersession headlines, and without it about 22 of the 27 survey flags would be restatements, which is the volume that produces rubber-stamped review. reverse gives supersession a way to withdraw, which the disputed-then-corrected case needed. The revise default makes the self-reported reason fail toward review. Lean section 7 checks strict within clean within live and the three per-reason outcomes.","supports":[["c138","d137"]],"depends_on":[],"answers":["q139"],"supersedes":[],"evidence":[],"revisit":"When a reviewer finds a restate that changed substance, or when flags pile up unreviewed","cost_if_wrong":"A schema field and derived flag state ship to every ledger; a wrong reason vocabulary means a migration of recorded reasons.","pinned":false,"choice":"Three reasons. restate: the commitment is unchanged; dependents resolve forward to the chain head and stay clean. revise: the substance changed; dependents resolve forward and are flagged for review. reverse: the retired record was wrong; dependents lose that ground and hold only through another complete set. A missing reason, including on every existing record, reads as revise.","alternatives":["No reason field: every supersession resolves forward and flags dependents for review.","Treat every supersession as withdrawal, so dependents lose the ground.","Resolve forward silently with no flag."],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d141","text":"Support that is circular and never grounded is flagged for review, not lost.","state":"adopted","ts":"2026-10-02T17:35:04+00:00","author":"claude-code","session":"","branch":"feat/supersession-reasons","scope":["docket/support.py","docs/definitions.md","experiments/lean-outcomes/**"],"rationale":"This repository's ledger has a cycle created only by forward resolution (c105 cites c104, c104 cites c62, c62 resolves to c105). The least fixed point made five records unsupported although nothing was withdrawn. The well-founded reading treats ungrounded support as unknown, and unknown maps to review.","supports":[["d140"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[{"ref":"tests/test_support_smoke.py"}],"revisit":"","cost_if_wrong":"Genuinely self-justifying records read as owing review rather than as lost.","pinned":false,"choice":"Evaluate a least and a greatest fixed point; a record where they differ is flagged with because: circular. A reversal, rejection or revocation inside the cycle still removes the ground. A later rising pass lets a review of the cycle's frontier clear the records above it.","alternatives":["Least fixed point only: circular support is unsupported."],"decided_by":"user"} +{"schema":2,"kind":"decision","id":"d142","text":"The revision digest excludes the derived support fields.","state":"adopted","ts":"2026-10-02T17:35:04+00:00","author":"claude-code","session":"","branch":"feat/supersession-reasons","scope":["docket/context_model.py"],"rationale":"The fields are a pure function of the hashed history, so the digest loses nothing, and --since tokens minted before the upgrade keep matching.","supports":[["d140"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"A change in evaluation code alone never moves the digest, so a resumed session is not told statuses moved after an upgrade.","pinned":false,"choice":"context_model._canonical_history drops support, review_owed and lost_grounds before hashing.","alternatives":["Hash them and regenerate every golden briefing, invalidating --since tokens on upgrade."],"decided_by":""} +{"schema":2,"kind":"decision","id":"d143","text":"An unsupported prerequisite does not block a decision; only a reversal or a rejected or revoked chain head blocks.","state":"adopted","ts":"2026-10-02T17:35:05+00:00","author":"claude-code","session":"","branch":"feat/supersession-reasons","scope":["docket/context_select.py","docket/context_delta.py","docket/ledger.py"],"rationale":"Applicability and support are separate axes: a decision's prerequisite gate follows the commitment chain, and a record's own grounds are reported on the record. Mixing them printed blocked lines beside applicable: true.","supports":[["d140"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"A decision resting on a claim whose grounds are gone shows no blocked line; the claim's own lost line carries the signal.","pinned":false,"choice":"Prerequisites resolve forward like supports; context_select._available keeps ignoring support status, and only the --since delta counts a newly unsupported record as no longer available.","alternatives":["Treat an unsupported record as unavailable everywhere, including briefing blocking and admission."],"decided_by":""} +{"schema":2,"kind":"correction","id":"d142.1","corrects":"d142","fields":{"rationale":"The fields are a pure function of the hashed history, so the digest loses nothing. A --since token minted before the upgrade keeps matching unless a decision's applicability changed under the new prerequisite rule; applicable and blocked_by are still hashed, so that ledger falls back to a full briefing."},"reason":"overstated token survival; applicability fields are still hashed","ts":"2026-10-02T20:00:35+00:00","author":"claude-code","session":"","branch":"feat/supersession-reasons"} +{"schema":2,"kind":"decision","id":"d144","text":"A review acknowledges only grounds whose problem the reviewed record owns.","state":"adopted","ts":"2026-10-02T20:00:35+00:00","author":"claude-code","session":"","branch":"feat/supersession-reasons","scope":["docket/support.py","docket/reviews.py","skills/docket/SKILL.md"],"rationale":"A pin is keyed on (ground, head); for a ground never superseded the head never moves, so pinning an inherited flag waived every later revision or block underneath it. Inherited flags clear on their own once the frontier record is reviewed.","supports":[["d140"]],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"A record resting on a blocked decision cannot be acknowledged from above; its owner must fix the decision.","pinned":false,"choice":"docket review pins revised, unassessed, disputed and circular grounds; a ground flagged because it is itself a flagged record, or a blocked decision, is cleared by reviewing or fixing that ground, and a review that would pin only those is refused with the grounds to look at.","alternatives":["Pin every owed ground, whatever its cause.","Store the cause in each pin and lift only while it still matches."],"decided_by":""} diff --git a/CHANGELOG.md b/CHANGELOG.md index 25cd855..22f0874 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- A supersession can carry `--supersede-reason restate|revise|reverse`, and every claim and decision derives `support` (`clean`, `flagged` or `unsupported`) with `review_owed` and `lost_grounds`. A citation follows supersessions to the head of its chain: a restatement keeps the citing record clean, a revision flags it, a reversal removes the ground. `unassessed`, `disputed` and blocked grounds flag; `rejected` and `revoked` grounds are lost. Support that is circular and never grounded is flagged with `because: circular` rather than lost. +- `docket review ID [--note TEXT]` acknowledges a record's own revised, unassessed, disputed or circular grounds against the heads it was reviewed against. A later revision of the same chain raises the flag again. A flag inherited from a flagged or blocked ground clears by reviewing or fixing that ground; `docket review` refuses a record that owes only those, and review the frontier first. +- `docket correct ID --supersede-reason VALUE` relabels a supersession. `--where` takes `is:flagged` and `is:unsupported`. `show --json` and `list --json` carry `support`, `review_owed`, `lost_grounds` and `reviews`. + +### Changed + +- A prerequisite in `depends_on` now follows restatements and revisions to the head of its chain instead of blocking the decision; only a reversal, or a rejected or revoked head, blocks. A `--since` token minted before the upgrade still matches unless a decision's applicability changed under the new prerequisite rule; then it prints a full briefing. +- Every supersession recorded before this release reads as `revise`, so records citing a superseded record owe review after upgrading. Relabel restatements with `docket correct ID --supersede-reason restate`. +- A ledger with any `supersede_reason`, a correction of one, or a review line cannot be read by docket 0.20.x or older. Upgrade every machine and plugin cache that reads the ledger first. + ## [0.20.2] - 2026-10-02 ### Fixed diff --git a/docket/cli/__init__.py b/docket/cli/__init__.py index ae61ee0..1005e84 100644 --- a/docket/cli/__init__.py +++ b/docket/cli/__init__.py @@ -13,8 +13,10 @@ from docket.cli.graph import cmd_graph from docket.cli.query import cmd_filter_ids, cmd_list, cmd_show, cmd_where from docket.cli.record import cmd_claim, cmd_decision, cmd_question +from docket.cli.review import add_review_parser from docket.cli.selfupdate import cmd_update, cmd_update_fetch from docket.ledger import KINDS, STATES, LedgerError +from docket.support import REASONS from docket.where import WhereError @@ -38,6 +40,7 @@ def _add_shared_args(p: argparse.ArgumentParser) -> None: p.add_argument("--depends-on", default="", metavar="CSV") p.add_argument("--answers", default="", metavar="CSV") p.add_argument("--supersedes", default="", metavar="CSV") + p.add_argument("--supersede-reason", choices=REASONS, help="restate, revise or reverse") p.add_argument("--evidence", action="append", default=[]) p.add_argument("--revisit", default="") p.add_argument("--cost", default="") @@ -55,7 +58,7 @@ def main(argv: list[str] | None = None) -> int: sub = p.add_subparsers( dest="cmd", metavar=( - "{claim,decision,question,correct,list,show,graph,context,where,check," + "{claim,decision,question,correct,review,list,show,graph,context,where,check," "rebase,migrate,init,feature,completion,update}" ), ) @@ -81,6 +84,7 @@ def main(argv: list[str] | None = None) -> int: qu.set_defaults(func=cmd_question) add_correct_parser(sub) + add_review_parser(sub) ls = sub.add_parser("list", help="list records") ls.add_argument("--kind", choices=KINDS) diff --git a/docket/cli/admin.py b/docket/cli/admin.py index 0870ae0..06faf25 100644 --- a/docket/cli/admin.py +++ b/docket/cli/admin.py @@ -144,6 +144,7 @@ def cmd_check(args: argparse.Namespace) -> int: highest = 0 records = 0 correction_lines = 0 + review_lines = 0 for number, line in enumerate(lines, 1): if not line.strip(): continue @@ -152,8 +153,11 @@ def cmd_check(args: argparse.Namespace) -> int: except json.JSONDecodeError as exc: faults.append(f"line {number}: invalid JSON: {exc.msg}") continue - if isinstance(record, dict) and record.get("kind") == "correction": - correction_lines += 1 + if isinstance(record, dict) and record.get("kind") in ("correction", "review"): + if record["kind"] == "review": + review_lines += 1 + else: + correction_lines += 1 # Kept out of seen: a feature that names a correction id names # nothing a brief can attach, and the feature check reports it. try: @@ -195,6 +199,8 @@ def cmd_check(args: argparse.Namespace) -> int: summary = f"{records} record{'s' if records != 1 else ''}" if correction_lines: summary += f" and {correction_lines} correction{'s' if correction_lines != 1 else ''}" + if review_lines: + summary += f" and {review_lines} review{'s' if review_lines != 1 else ''}" print(f"docket: {path} reads cleanly, {summary}") else: print(f"docket: {path} has {len(faults)} fault{'s' if len(faults) != 1 else ''}") diff --git a/docket/cli/completion.py b/docket/cli/completion.py index 6f38eeb..537c3e5 100644 --- a/docket/cli/completion.py +++ b/docket/cli/completion.py @@ -21,6 +21,8 @@ "--depends-on", "--answers", "--supersedes", + "--supersede-reason", + "--note", "--evidence", "--revisit", "--cost", @@ -54,6 +56,7 @@ "decision", "question", "correct", + "review", "list", "show", "graph", @@ -120,7 +123,8 @@ fi case "$prev" in --state) COMPREPLY=($(compgen -W "unassessed accepted disputed rejected adopted revoked open resolved" -- "$cur")); return ;; - --supports|--depends-on|--answers|--supersedes|show) + --supersede-reason) COMPREPLY=($(compgen -W "restate revise reverse" -- "$cur")); return ;; + --supports|--depends-on|--answers|--supersedes|show|review) COMPREPLY=($(compgen -W "$(docket list --oneline 2>/dev/null | awk '{{print $1}}')" -- "$cur")); return ;; completion) COMPREPLY=($(compgen -W "bash zsh fish" -- "$cur")); return ;; esac @@ -146,7 +150,7 @@ '*::arg:->args' case $words[1] in - show) _docket_ids ;; + show|review) _docket_ids ;; completion) _values 'shell' bash zsh fish ;; feature) case $words[2] in @@ -171,6 +175,7 @@ '--depends-on[decision prerequisites]:id:_docket_ids' \\ '--answers[question ids]:id:_docket_ids' \\ '--supersedes[retired ids]:id:_docket_ids' \\ + '--supersede-reason[why it supersedes]:reason:(restate revise reverse)' \\ '--cost[cost if wrong]:cost:' ;; esac @@ -179,7 +184,9 @@ _FISH_COMPLETION = f"""\ set -l docket_cmds {" ".join(_COMPLETION_CMDS)} complete -c docket -n "not __fish_seen_subcommand_from $docket_cmds" -a "$docket_cmds" -complete -c docket -n "__fish_seen_subcommand_from show" -a "(docket list --oneline 2>/dev/null | awk '{{print \\$1}}')" +complete -c docket -n "__fish_seen_subcommand_from claim decision question correct" -l supersede-reason -a "restate revise reverse" +complete -c docket -n "__fish_seen_subcommand_from review" -l note -r +complete -c docket -n "__fish_seen_subcommand_from show review" -a "(docket list --oneline 2>/dev/null | awk '{{print \\$1}}')" complete -c docket -n "__fish_seen_subcommand_from claim decision question" -l supports -a "(docket list --oneline 2>/dev/null | awk '{{print \\$1}}')" complete -c docket -n "__fish_seen_subcommand_from claim decision question" -l supersedes -a "(docket list --oneline 2>/dev/null | awk '{{print \\$1}}')" complete -c docket -n "__fish_seen_subcommand_from claim decision" -l state -a "unassessed accepted disputed rejected adopted revoked" diff --git a/docket/cli/correct.py b/docket/cli/correct.py index ddf08e3..5d9ccee 100644 --- a/docket/cli/correct.py +++ b/docket/cli/correct.py @@ -3,6 +3,7 @@ from docket import corrections, env from docket.ledger import ID_RE, LedgerError, append, parse_evidence +from docket.support import REASONS # Flags that change what a record commits to. Accepted by the parser only so # the refusal can name supersession instead of argparse's generic error. @@ -40,6 +41,8 @@ def _fields(args: argparse.Namespace) -> dict: fields["alternatives"] = args.alternative if args.decided_by is not None: fields["decided_by"] = args.decided_by + if args.supersede_reason is not None: + fields["supersede_reason"] = args.supersede_reason for name in args.clear: if name in fields: raise LedgerError(f"docket: --clear {name} and {CLEAR_FLAGS[name]} conflict") @@ -95,6 +98,9 @@ def add_correct_parser(sub) -> None: pin.add_argument("--unpin", action="store_true") co.add_argument("--alternative", action="append", default=[]) co.add_argument("--decided-by") + co.add_argument( + "--supersede-reason", choices=REASONS, help="relabel why this record superseded another" + ) co.add_argument( "--clear", action="append", default=[], choices=CLEARABLE, help="set a list field to empty" ) diff --git a/docket/cli/graph.py b/docket/cli/graph.py index 1f0c7c0..29abf49 100644 --- a/docket/cli/graph.py +++ b/docket/cli/graph.py @@ -11,7 +11,7 @@ from pathlib import Path from typing import Any -from docket import ROOT, env, where +from docket import ROOT, env, support, where from docket.cli.term import ( _DIM, _GRAPH_GLYPHS, @@ -68,6 +68,9 @@ def _node_info(e: dict, retired: dict[str, str]) -> dict: "applicable": e.get("applicable"), "blocked_by": e.get("blocked_by", []), "decided_by": e.get("decided_by", ""), + "support": e.get("support"), + "review_owed": e.get("review_owed", []), + "lost_grounds": e.get("lost_grounds", []), } @@ -78,6 +81,10 @@ def _formula(sets: list[list[str]]) -> str: def _blocked_text(info: dict) -> str: if info.get("kind") == "decision" and info.get("applicable") is False: return "! blocked by " + (", ".join(info.get("blocked_by", [])) or "prerequisites") + if support.surfaced(info, "unsupported"): + return "! lost " + ", ".join(o["ground"] for o in info["lost_grounds"]) + if support.surfaced(info, "flagged"): + return "? review " + ", ".join(o["ground"] for o in info["review_owed"]) return "" diff --git a/docket/cli/query.py b/docket/cli/query.py index a347517..3ed64b3 100644 --- a/docket/cli/query.py +++ b/docket/cli/query.py @@ -12,7 +12,7 @@ import sys import textwrap -from docket import corrections, env, where +from docket import corrections, env, support, where from docket.cli.term import _DIM, _STATE_COLOR, _c, _match, _use_color from docket.context_model import positions from docket.env import LEDGER, justification_sets, read, retired_by @@ -169,6 +169,15 @@ def field(label: str, value: str) -> None: field("Cost if wrong", e["cost_if_wrong"]) if e.get("corrections"): field("Corrections", ", ".join(e["corrections"])) + if support.surfaced(e, "flagged"): + field( + "Review owed", + ", ".join(f"{o['ground']} -> {o['head']} ({o['because']})" for o in e["review_owed"]), + ) + if support.surfaced(e, "unsupported"): + field( + "Lost grounds", ", ".join(f"{o['ground']} ({o['because']})" for o in e["lost_grounds"]) + ) field("Recorded state", e.get("recorded_state", e.get("state", ""))) print(f" Recorded: {e.get('ts', '')}") print( diff --git a/docket/cli/record.py b/docket/cli/record.py index 2141d70..3049c35 100644 --- a/docket/cli/record.py +++ b/docket/cli/record.py @@ -19,6 +19,7 @@ def _shared_fields(args: argparse.Namespace) -> dict: "depends_on": [ref.strip() for ref in (args.depends_on or "").split(",") if ref.strip()], "answers": [ref.strip() for ref in (args.answers or "").split(",") if ref.strip()], "supersedes": [ref.strip() for ref in (args.supersedes or "").split(",") if ref.strip()], + "supersede_reason": args.supersede_reason, "evidence": [parse_evidence(item) for item in (args.evidence or [])], "revisit": args.revisit or "", "cost_if_wrong": args.cost or "", @@ -31,6 +32,8 @@ def _shared_fields(args: argparse.Namespace) -> dict: def _append_cli(kind: str, args: argparse.Namespace) -> int: try: + if args.supersede_reason and not (args.supersedes or "").strip(): + raise LedgerError("docket: --supersede-reason needs --supersedes") fields = _shared_fields(args) if kind == "decision": fields.update( @@ -53,6 +56,13 @@ def _append_cli(kind: str, args: argparse.Namespace) -> int: # Hints go to stderr so a caller piping the record line is unaffected. for hint in reasoning_hints(entry): print(f"docket: {entry['id']}: {hint}", file=sys.stderr) + if entry["supersedes"] and "supersede_reason" not in entry and entry["kind"] != "question": + print( + f"docket: {entry['id']}: recorded as revise; records citing " + f"{', '.join(entry['supersedes'])} will owe review. Pass --supersede-reason " + "restate if only wording or links changed.", + file=sys.stderr, + ) return 0 diff --git a/docket/cli/review.py b/docket/cli/review.py new file mode 100644 index 0000000..a8e97af --- /dev/null +++ b/docket/cli/review.py @@ -0,0 +1,35 @@ +import argparse +import sys + +from docket import env, reviews +from docket.ledger import ID_RE, LedgerError, append + + +def cmd_review(args: argparse.Namespace) -> int: + if not ID_RE.fullmatch(args.id): + print("docket: review names a claim or decision id", file=sys.stderr) + return 1 + try: + entry = append( + env.ledger_path(), + reviews.make( + args.id, + note=args.note, + author=env.resolved_author(), + session=env.session_id(), + branch=env.branch(env.project_root()), + ), + ) + except (LedgerError, OSError) as exc: + print(str(exc), file=sys.stderr) + return 1 + pairs = ", ".join(f"{ground} -> {head}" for ground, head in entry["grounds"].items()) + print(f"{entry['id']} reviews {entry['reviews']}: {pairs}") + return 0 + + +def add_review_parser(sub) -> None: + rv = sub.add_parser("review", help="acknowledge that a flagged record still stands") + rv.add_argument("id", help="the claim or decision to review") + rv.add_argument("--note", default="", help="what the review concluded") + rv.set_defaults(func=cmd_review) diff --git a/docket/context.py b/docket/context.py index 05e1345..c617f26 100644 --- a/docket/context.py +++ b/docket/context.py @@ -18,6 +18,7 @@ from collections.abc import Iterable, Mapping from typing import Any +from docket import support from docket.config import DEFAULTS as _SETTINGS_DEFAULTS from docket.context_budget import Admission from docket.context_degrade import degrade @@ -194,6 +195,7 @@ def build_context( no_match=no_match, blocking_cache=blocking_cache, verify=_VERIFY_TRIAL, + owed_count=sum(support.surfaced(item, "flagged") for item in history), ) budget.shrink_prefix(feature_prefix + short) diff --git a/docket/context_budget.py b/docket/context_budget.py index 7123a61..638a7bb 100644 --- a/docket/context_budget.py +++ b/docket/context_budget.py @@ -42,6 +42,7 @@ def __init__( no_match: bool, blocking_cache: dict[str, list[list[str]]], verify: bool = False, + owed_count: int = 0, ) -> None: self.by_id = by_id self.current_ids = current_ids @@ -54,6 +55,7 @@ def __init__( self.soft_limit = soft_limit self.hard_limit = hard_limit self.retired_count = retired_count + self.owed_count = owed_count self.no_match = no_match self.blocking_cache = blocking_cache self.verify = verify @@ -107,6 +109,7 @@ def footer_text(self, included_count, shown, deferred_count, related_count, miss related_count, missing_count, retired_count=self.retired_count, + owed_count=self.owed_count, no_match=self.no_match, ) diff --git a/docket/context_delta.py b/docket/context_delta.py index f51eabf..27ae70e 100644 --- a/docket/context_delta.py +++ b/docket/context_delta.py @@ -10,6 +10,7 @@ from collections.abc import Iterable, Mapping from typing import Any +from docket import support from docket.config import DEFAULTS as _SETTINGS_DEFAULTS from docket.context_model import _clip_metadata, _id, _revision, positions from docket.context_render import _index_line, _render_record @@ -52,7 +53,11 @@ def build_delta( if expected and _revision(baseline) != expected: # A rebase renumbers the tail, so this ID now covers different history. return None - was_available = {_id(item) for item in baseline if _available(item)} + was_available = { + _id(item) + for item in baseline + if _available(item) and not support.surfaced(item, "unsupported") + } cfg = settings if settings is not None else _SETTINGS_DEFAULTS limit = max_chars if max_chars is not None else cfg["budget"]["target"] corrected_ids = { @@ -69,8 +74,19 @@ def build_delta( for item in history if at[_id(item)] <= cutoff and _id(item) in was_available - and not _available(item) + and (not _available(item) or support.surfaced(item, "unsupported")) + and _id(item) not in corrected_ids + ] + changed_ids = {_id(item) for item in changed} + was_flagged = {_id(item) for item in baseline if support.surfaced(item, "flagged")} + newly_flagged = [ + item + for item in history + if at[_id(item)] <= cutoff + and support.surfaced(item, "flagged") + and _id(item) not in was_flagged and _id(item) not in corrected_ids + and _id(item) not in changed_ids ] latest = _id(lines[-1]) if lines else "" revision = _revision(history) @@ -80,13 +96,13 @@ def build_delta( f"# docket: {_clip_metadata(ledger or 'ledger', 180)} | revision: {revision}" f" | latest: {latest}@{revision} | since: {since}", f"# changed: {len(added)} added, {len(corrected)} corrected, " - f"{len(changed)} no longer available.", + f"{len(changed)} no longer available, {len(newly_flagged)} newly owe review.", ] ) + "\n\n" ) blocks: list[str] = [] - for item in added + corrected + changed: + for item in added + corrected + changed + newly_flagged: block = _render_record(item, "changed", "", by_id) if len(head) + len("\n\n".join(blocks + [block])) > limit: block = _index_line(item, cfg["index"]["detail_min"]) diff --git a/docket/context_model.py b/docket/context_model.py index 28634b3..66a6082 100644 --- a/docket/context_model.py +++ b/docket/context_model.py @@ -30,10 +30,14 @@ def _json(value: Any) -> str: return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str) +_DERIVED = frozenset({"support", "review_owed", "lost_grounds"}) + + def _canonical_history(entries: list[Mapping[str, Any]]) -> str: - return json.dumps( - entries, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str - ) + # Derived from the hashed history, so including them would change every + # digest whenever the evaluation code changes. + kept = [{k: v for k, v in entry.items() if k not in _DERIVED} for entry in entries] + return json.dumps(kept, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str) def _revision(entries: list[Mapping[str, Any]]) -> str: diff --git a/docket/context_render.py b/docket/context_render.py index 1cab1d5..1a7bbb3 100644 --- a/docket/context_render.py +++ b/docket/context_render.py @@ -10,6 +10,7 @@ from collections.abc import Mapping from typing import Any +from docket import support from docket.context_model import ( _clip_metadata, _effective_state, @@ -93,6 +94,7 @@ def _footer( missing_count: int, *, retired_count: int, + owed_count: int = 0, no_match: bool, ) -> str: """The counts and the caveats that close every briefing.""" @@ -105,6 +107,8 @@ def _footer( lines.append(f"# Not listed: {deferred_count - shown}; reach them with docket list.") if related_count: lines.append(f"# Related records in index only: {related_count}; formulas remain complete.") + if owed_count: + lines.append(f"# owe review: {owed_count}; docket list --where is:flagged") # The measured set is the caller's own task matches plus their prerequisite # closure. The closure alone reads "covered" almost always, because a # blocking chain is admitted right after its root; the whole index reads @@ -166,13 +170,17 @@ def _render_record( if by_id: for path in _blocking_paths(_id(entry), by_id, cache=blocking_cache): terminal = by_id.get(path[-1], {}) - lines.append(f"blocked: {' -> '.join(path)} {_unavailable_reason(terminal)}") + lines.append(f"blocked: {' -> '.join(path)} {_unavailable_reason(terminal, by_id)}") if _text(entry.get("decided_by")): lines.append(f"decided by: {_text(entry.get('decided_by'))}") for field in ("scope", "supports", "depends_on", "answers", "supersedes"): value = entry.get(field) if _list(value): lines.append(f"{field}: {_json(_list(value))}") + if support.surfaced(entry, "flagged"): + lines.append(f"review_owed: {_json(_list(entry.get('review_owed')))}") + elif support.surfaced(entry, "unsupported"): + lines.append(f"lost: {_json(_list(entry.get('lost_grounds')))}") for field in ("rationale", "revisit", "cost_if_wrong"): value = _text(entry.get(field)) if value and not ( diff --git a/docket/context_select.py b/docket/context_select.py index f80131c..9146331 100644 --- a/docket/context_select.py +++ b/docket/context_select.py @@ -9,6 +9,7 @@ from collections.abc import Mapping from typing import Any +from docket import support from docket.context_model import ( _effective_state, _id, @@ -130,7 +131,7 @@ def _available(entry: Mapping[str, Any]) -> bool: return False -def _unavailable_reason(entry: Mapping[str, Any]) -> str: +def _unavailable_reason(entry: Mapping[str, Any], by_id: Mapping[str, Mapping[str, Any]]) -> str: """Why a record cannot serve as current support. The effective state does not say this. A retired claim still reads @@ -139,12 +140,32 @@ def _unavailable_reason(entry: Mapping[str, Any]) -> str: """ if _is_retired(entry): - return "retired" + _, crossed = _resolve(_id(entry), by_id) + return "reversed" if "reverse" in crossed else "retired" if _text(entry.get("kind")).casefold() == "decision" and entry.get("applicable") is False: return "blocked" return _effective_state(entry) +def _resolve(ident: str, by_id: Mapping[str, Mapping[str, Any]]) -> tuple[str, list[str]]: + """Follow supersessions to the head, as the support evaluation does. + + The maps are built only for a retired record, so the common case of a live + prerequisite costs nothing. + """ + + if not _is_retired(by_id.get(ident, {})): + return ident, [] + retired = { + _id(item): _text(item.get("retired_by")) for item in by_id.values() if _is_retired(item) + } + reason_of = { + _id(item): _text(item.get("supersede_reason")) or support.DEFAULT_REASON + for item in by_id.values() + } + return support.head_of(ident, retired, reason_of) + + def _blocking_paths( ident: str, by_id: Mapping[str, Mapping[str, Any]], @@ -179,11 +200,15 @@ def walk(current: str, trail: tuple[str, ...]) -> None: target_id = _text(target) if not target_id or target_id in trail or target_id not in by_id: continue - if _available(by_id[target_id]): + head, crossed = _resolve(target_id, by_id) + if "reverse" in crossed: + paths.append(list(trail[1:]) + [target_id]) + continue + if head in trail or head not in by_id or _available(by_id[head]): continue - step = trail + (target_id,) + step = trail + (head,) before = len(paths) - walk(target_id, step) + walk(head, step) if len(paths) == before: paths.append(list(step[1:])) diff --git a/docket/corrections.py b/docket/corrections.py index 775daf0..a2ae8ef 100644 --- a/docket/corrections.py +++ b/docket/corrections.py @@ -15,7 +15,16 @@ KIND = "correction" CORRECTION_RE = re.compile(r"([cdq](?:0|[1-9][0-9]*))\.([1-9][0-9]*)") _COMMON = frozenset( - {"text", "rationale", "scope", "cost_if_wrong", "evidence", "revisit", "pinned"} + { + "text", + "rationale", + "scope", + "cost_if_wrong", + "evidence", + "revisit", + "pinned", + "supersede_reason", + } ) _DECISION_ONLY = frozenset({"alternatives", "decided_by"}) _LINE_FIELDS = frozenset( diff --git a/docket/ledger.py b/docket/ledger.py index 6b12aae..60dee8f 100644 --- a/docket/ledger.py +++ b/docket/ledger.py @@ -17,7 +17,7 @@ from pathlib import Path from typing import Any, Iterator -from docket import corrections +from docket import corrections, reviews, support SCHEMA = 2 KINDS = ("claim", "decision", "question") @@ -44,7 +44,7 @@ ) DECISION_FIELDS = frozenset({"choice", "alternatives", "decided_by"}) AUDIT_FIELDS = frozenset({"legacy"}) -ALLOWED_FIELDS = COMMON_FIELDS | DECISION_FIELDS | AUDIT_FIELDS +ALLOWED_FIELDS = COMMON_FIELDS | DECISION_FIELDS | AUDIT_FIELDS | {"supersede_reason"} class LedgerError(ValueError): @@ -184,6 +184,7 @@ def make_record( depends_on: list[str] | None = None, answers: list[str] | None = None, supersedes: list[str] | None = None, + supersede_reason: str | None = None, evidence: list[dict[str, str]] | None = None, revisit: str = "", cost_if_wrong: str = "", @@ -223,6 +224,8 @@ def make_record( "cost_if_wrong": cost_if_wrong, "pinned": pinned, } + if supersede_reason is not None: + record["supersede_reason"] = supersede_reason _reject_question_text(record) if kind == "decision": if alternatives is not None and not isinstance(alternatives, list): @@ -259,17 +262,22 @@ class _Prefix: that validates in order updates one of these instead. """ - __slots__ = ("by_id", "max_number", "retired", "corrections") + __slots__ = ("by_id", "max_number", "retired", "corrections", "reviews") def __init__(self, entries: list[dict[str, Any]]) -> None: self.by_id: dict[str, dict[str, Any]] = {} self.max_number = 0 self.retired: dict[str, str] = {} self.corrections: dict[str, int] = {} + self.reviews: dict[str, int] = {} for entry in entries: self.add(entry) def add(self, entry: dict[str, Any]) -> None: + if entry.get("kind") == reviews.KIND: + target, number = reviews.parts_of(entry["id"]) + self.reviews[target] = max(self.reviews.get(target, 0), number) + return # A correction is no relation target and carries no supersedes. if entry.get("kind") == corrections.KIND: target, number = corrections.parts_of(entry["id"]) @@ -301,6 +309,12 @@ def validate_record( if prefix is None and previous is not None: prefix = _Prefix(previous) return corrections.validate(record, prefix) + if record.get("kind") == reviews.KIND: + if prefix is not None and previous is not None: + raise _error("record", "pass previous or prefix, not both") + if prefix is None and previous is not None: + prefix = _Prefix(previous) + return reviews.validate(record, prefix) if record.get("schema") in (None, 1): raise _error( "schema", "legacy format is unsupported; run 'docket migrate' to convert it to schema 2" @@ -413,6 +427,11 @@ def validate_record( raise _error(record_id, "questions cannot answer other questions") if kind != "decision" and record["depends_on"]: raise _error(record_id, "only decisions may have depends_on") + if "supersede_reason" in record: + if record["supersede_reason"] not in support.REASONS: + raise _error(record_id, f"supersede_reason must be one of {', '.join(support.REASONS)}") + if not record["supersedes"]: + raise _error(record_id, "supersede_reason needs supersedes") if prefix is not None and previous is not None: raise _error(record_id, "pass previous or prefix, not both") @@ -561,14 +580,28 @@ def _decision_applicability( by_id = {entry["id"]: entry for entry in entries} applicable: dict[str, bool] = {} blocked: dict[str, list[str]] = {} + reason_of = { + entry["id"]: entry.get("supersede_reason", support.DEFAULT_REASON) for entry in entries + } # Validation refuses a reference to a later id, so a validated ledger is # acyclic. project(validated=True) skips that check, and a cycle there would # otherwise recurse until the stack ends. One shared set costs nothing. visiting: set[str] = set() + hops = 0 + + class _ForwardCycle(Exception): + pass def check(entry_id: str) -> tuple[bool, list[str]]: if entry_id in visiting: + # A cycle in the recorded depends_on graph is a corrupt ledger. One + # that closes only through a supersession hop is valid, because every + # record cites earlier ids; there the prerequisite rests on itself and + # holds nothing, so the decision is blocked (least fixed point). A + # `supports` cycle is flagged circular instead. + if hops: + raise _ForwardCycle raise _error(entry_id, "depends_on forms a cycle") visiting.add(entry_id) try: @@ -592,7 +625,20 @@ def _check(entry_id: str) -> tuple[bool, list[str]]: blockers: list[str] = [] seen: set[str] = set() for dependency in entry["depends_on"]: - ok, reasons = check(dependency) + head, crossed = support.head_of(dependency, retired, reason_of) + if "reverse" in crossed: + ok, reasons = False, [head] + elif head == dependency: + ok, reasons = check(head) + else: + nonlocal hops + hops += 1 + try: + ok, reasons = check(head) + except _ForwardCycle: + ok, reasons = False, [head] + finally: + hops -= 1 if not ok: for reason in [dependency, *reasons]: if reason not in seen: @@ -618,9 +664,11 @@ def project(entries: list[dict[str, Any]], *, validated: bool = False) -> list[d if not validated: entries = validate_entries(entries) entries = corrections.fold(entries) + entries = reviews.fold(entries) retired = retired_by(entries) answers = resolved_by(entries) applicability, blocked = _decision_applicability(entries, retired) + statuses = support.evaluate(entries, retired, applicability) result = [] for entry in entries: # Shallow by design. Only top-level keys are added below, and the two @@ -636,6 +684,8 @@ def project(entries: list[dict[str, Any]], *, validated: bool = False) -> list[d if entry["kind"] == "decision": projected["applicable"] = applicability.get(entry["id"], False) projected["blocked_by"] = list(blocked.get(entry["id"], [])) + if entry["id"] in statuses: + projected.update(statuses[entry["id"]]) result.append(projected) return result @@ -731,6 +781,13 @@ def append(path: Path | str, record: dict[str, Any]) -> dict[str, Any]: validate_record(candidate, previous=entries) if from_cli: corrections.refuse(entries, candidate) + elif candidate.get("kind") == reviews.KIND: + if not candidate.get("id"): + candidate["id"] = reviews.allocate(entries, str(candidate.get("reviews", ""))) + # Grounds are what the record owes under this lock, not what the + # caller saw before it. + reviews.refuse(entries, candidate) + validate_record(candidate, previous=entries) else: if not candidate.get("id"): kind = candidate.get("kind") or "" diff --git a/docket/rebase.py b/docket/rebase.py index dbca98d..fff390b 100644 --- a/docket/rebase.py +++ b/docket/rebase.py @@ -18,7 +18,7 @@ from collections.abc import Mapping, Sequence from typing import Any -from docket import corrections +from docket import corrections, reviews from docket.ledger import ID_RE, allocate_id @@ -88,6 +88,10 @@ def renumber( target = mapping.get(str(record.get("corrects")), str(record.get("corrects"))) record["corrects"] = target new = corrections.allocate(allocated, target) + elif record.get("kind") == reviews.KIND: + target = mapping.get(str(record.get("reviews")), str(record.get("reviews"))) + record["reviews"] = target + new = reviews.allocate(allocated, target) else: if not ID_RE.fullmatch(old): raise RebaseError(f"malformed id {old!r} in the incoming tail") @@ -97,6 +101,11 @@ def renumber( allocated.append(record) for record in tail: + if record.get("kind") == reviews.KIND: + record["grounds"] = { + mapping.get(str(g), str(g)): mapping.get(str(h), str(h)) + for g, h in record["grounds"].items() + } for field in _REFERENCE_FIELDS: values = record.get(field) if values: diff --git a/docket/reviews.py b/docket/reviews.py new file mode 100644 index 0000000..a55b450 --- /dev/null +++ b/docket/reviews.py @@ -0,0 +1,150 @@ +"""Review lines: a reader's acknowledgment that a flagged record still stands. + +A review pins each ground the record itself owes, to the head it resolved to +when reviewed. The flag clears only while that head is still the head, so a +later revision of the same chain raises it again. A flag inherited from a +flagged or blocked ground is never pinned; it clears when that ground does. +""" + +from __future__ import annotations + +import copy +import re +from typing import Any + +from docket.support import INHERITED + +KIND = "review" +REVIEW_RE = re.compile(r"([cdq](?:0|[1-9][0-9]*))\.r([1-9][0-9]*)") +_LINE_FIELDS = frozenset( + {"schema", "kind", "id", "reviews", "grounds", "note", "ts", "author", "session", "branch"} +) + + +def split_id(ident: str) -> tuple[str, int] | None: + match = REVIEW_RE.fullmatch(ident) + return (match.group(1), int(match.group(2))) if match else None + + +def parts_of(ident: str) -> tuple[str, int]: + parts = split_id(ident) + if parts is None: + raise ValueError(f"not a review id: {ident!r}") + return parts + + +def validate(record: dict[str, Any], prefix: Any) -> dict[str, Any]: + """Validate a review line; ``prefix`` is a ledger._Prefix or None.""" + from docket.ledger import SCHEMA, _error + + ident = record.get("id") + if not isinstance(ident, str) or (parts := split_id(ident)) is None: + raise _error("record", "review id must match .r with n from 1") + unknown = sorted(set(record) - _LINE_FIELDS) + if unknown: + raise _error(ident, f"unknown field(s): {', '.join(unknown)}") + if type(record.get("schema")) is not int or record["schema"] != SCHEMA: + raise _error("schema", f"expected schema {SCHEMA}, got {record.get('schema')!r}") + for field in ("ts", "author", "session", "branch", "note"): + if not isinstance(record.get(field), str): + raise _error(ident, f"{field} must be a string") + target_id, number = parts + if record.get("reviews") != target_id: + raise _error(ident, "a review id must start with the id it reviews") + grounds = record.get("grounds") + if ( + not isinstance(grounds, dict) + or not grounds + or not all(isinstance(k, str) and isinstance(v, str) for k, v in grounds.items()) + ): + raise _error(ident, "grounds must be a non-empty object of ground id to head id") + if prefix is None: + return copy.deepcopy(record) + target = prefix.by_id.get(target_id) + if target is None or target["kind"] not in ("claim", "decision"): + raise _error(ident, f"reviews unknown, later, or non-claim/decision ID {target_id!r}") + for ground, head in grounds.items(): + for ref in (ground, head): + if ref not in prefix.by_id: + raise _error(ident, f"grounds refer to unknown or later ID {ref!r}") + if number <= prefix.reviews.get(target_id, 0): + raise _error(ident, "review numbers must increase for each record; gaps are allowed") + return copy.deepcopy(record) + + +def fold(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Records with their review lines attached as ``reviews``, the lines removed.""" + result: list[dict[str, Any]] = [] + index: dict[str, int] = {} + for entry in entries: + if entry.get("kind") != KIND: + index[entry["id"]] = len(result) + result.append(entry) + continue + position = index[entry["reviews"]] + current = dict(result[position]) + current["reviews"] = [ + *current.get("reviews", []), + {"id": entry["id"], "grounds": dict(entry["grounds"]), "note": entry["note"]}, + ] + result[position] = current + return result + + +def allocate(entries: list[dict[str, Any]], target: str) -> str: + highest = 0 + for entry in entries: + if entry.get("kind") == KIND and entry.get("reviews") == target: + highest = max(highest, parts_of(entry["id"])[1]) + return f"{target}.r{highest + 1}" + + +def make( + target: str, + *, + note: str = "", + author: str = "unknown", + session: str = "", + branch: str = "", + ts: str | None = None, +) -> dict[str, Any]: + """An unnumbered review line; append allocates the id and fills grounds under its lock.""" + from datetime import datetime, timezone + + from docket.ledger import SCHEMA + + return { + "schema": SCHEMA, + "kind": KIND, + "id": "", + "reviews": target, + "grounds": {}, + "note": note, + "ts": ts if ts is not None else datetime.now(timezone.utc).isoformat(timespec="seconds"), + "author": author, + "session": session, + "branch": branch, + } + + +def refuse(entries: list[dict[str, Any]], review: dict[str, Any]) -> None: + """Fill ``grounds`` with what the record owes now, or refuse a review of nothing.""" + from docket.ledger import _error, project + + target = next( + (e for e in project(entries, validated=True) if e["id"] == review["reviews"]), None + ) + if target is None or target["kind"] not in ("claim", "decision"): + raise _error(review["id"], f"no claim or decision {review['reviews']!r} to review") + owed = target.get("review_owed") or [] + if not owed: + raise _error(review["id"], "nothing to review: the record owes no review") + own = [item for item in owed if item["because"] not in INHERITED] + if not own: + grounds = ", ".join(dict.fromkeys(item["ground"] for item in owed)) + raise _error( + review["id"], + f"nothing to review on {review['reviews']}: its flags come from {grounds}; " + "review or fix those", + ) + review["grounds"] = {item["ground"]: item["head"] for item in own} diff --git a/docket/support.py b/docket/support.py new file mode 100644 index 0000000..2a05e52 --- /dev/null +++ b/docket/support.py @@ -0,0 +1,177 @@ +"""Whether a record's declared grounds still stand. + +A citation follows supersessions to the head of its chain. The reason on each +supersession decides what the citing record keeps: a restatement keeps it +clean, a revision flags it for review, a reversal removes the ground. The +model and its proofs are in experiments/lean-outcomes/STRESS-TESTS.md, section 7. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +REASONS = ("restate", "revise", "reverse") +# A missing reason costs a review flag and never hides one. +DEFAULT_REASON = "revise" + +UNSUPPORTED, FLAGGED, CLEAN = 0, 1, 2 +NAMES = {UNSUPPORTED: "unsupported", FLAGGED: "flagged", CLEAN: "clean"} +INHERITED = ("flagged", "blocked") +_SURFACED_STATES = ("accepted", "adopted") + + +def head_of( + ident: str, retired: Mapping[str, str], reason_of: Mapping[str, str] +) -> tuple[str, list[str]]: + """The current record a citation resolves to, and the reasons crossed on the way. + + Docket refuses to supersede a retired record, so each record has at most one + successor and the walk ends. + """ + crossed: list[str] = [] + while ident in retired: + ident = retired[ident] + crossed.append(reason_of.get(ident, DEFAULT_REASON)) + return ident, crossed + + +def surfaced(entry: Mapping[str, Any], status: str) -> bool: + """Whether a projected record shows ``status`` to a reader.""" + return ( + entry.get("support") == status + and not entry.get("retired_by") + and entry.get("recorded_state", entry.get("state")) in _SURFACED_STATES + ) + + +def evaluate( + entries: list[dict[str, Any]], + retired: Mapping[str, str], + applicable: Mapping[str, bool], +) -> dict[str, dict[str, Any]]: + """Support status for every claim and decision in folded ``entries``.""" + by_id = {entry["id"]: entry for entry in entries} + reason_of = {entry["id"]: entry.get("supersede_reason", DEFAULT_REASON) for entry in entries} + graded = [entry for entry in entries if entry["kind"] in ("claim", "decision")] + circular: set[str] = set() + pins = { + entry["id"]: { + (ground, head) + for review in entry.get("reviews", []) + for ground, head in review["grounds"].items() + } + for entry in graded + } + + def ground(owner: str, cited: str, level: Mapping[str, int]) -> tuple[int, str, str]: + head, crossed = head_of(cited, retired, reason_of) + target = by_id[head] + value, because = CLEAN, "" + for applies, to, why in ( + ("reverse" in crossed, UNSUPPORTED, "reverse"), + (target["state"] in ("rejected", "revoked"), UNSUPPORTED, target["state"]), + (level.get(head) == UNSUPPORTED, UNSUPPORTED, "unsupported"), + (head in circular, FLAGGED, "circular"), + ("revise" in crossed, FLAGGED, "revise"), + (target["state"] in ("unassessed", "disputed"), FLAGGED, target["state"]), + (target["kind"] == "decision" and applicable.get(head) is False, FLAGGED, "blocked"), + (level.get(head) == FLAGGED, FLAGGED, "flagged"), + ): + if applies and to < value: + value, because = to, why + # A flag inherited from the head's own flag or block lives at the head, + # and a head that was never superseded never moves, so a pin there + # would waive every later cause for good. `because` names only the + # first cause, so a pin lifts the owned causes and re-tests these two. + # A circular head sits at FLAGGED as an owned cause, but is inherited + # when the greatest fixed point also flags it. `high` exists whenever + # `circular` is non-empty. + if value == FLAGGED and (cited, head) in pins[owner]: + value, because = CLEAN, "" + if target["kind"] == "decision" and applicable.get(head) is False: + value, because = FLAGGED, "blocked" + elif level.get(head) == FLAGGED and (head not in circular or high[head] == FLAGGED): + value, because = FLAGGED, "flagged" + return value, head, because + + def prerequisites(entry: Mapping[str, Any]) -> list[tuple[str, str]]: + """Revised prerequisites the decision has not reviewed; reversals block instead.""" + owed = [] + for cited in entry.get("depends_on", []): + head, crossed = head_of(cited, retired, reason_of) + if ( + "revise" in crossed + and "reverse" not in crossed + and (cited, head) not in pins[entry["id"]] + ): + owed.append((cited, head)) + return owed + + def record_level(entry: Mapping[str, Any], level: Mapping[str, int]) -> int: + value = CLEAN + if entry["supports"]: + value = max( + min(ground(entry["id"], cited, level)[0] for cited in group) + for group in entry["supports"] + ) + if prerequisites(entry): + value = min(value, FLAGGED) + return value + + def fixed_point(start: int) -> dict[str, int]: + level = {entry["id"]: start for entry in graded} + changed = True + while changed: + changed = False + for entry in graded: + new = record_level(entry, level) + if new != level[entry["id"]]: + level[entry["id"]] = new + changed = True + return level + + # Support that only the least fixed point denies is circular: nothing + # grounds it, but nothing withdrew it either, so it is flagged, not lost. + low, high = fixed_point(UNSUPPORTED), fixed_point(CLEAN) + circular.update(ident for ident in low if low[ident] != high[ident]) + level = {ident: FLAGGED if ident in circular else low[ident] for ident in low} + # A review pin lifts a circular ground only when ground() sees it as + # FLAGGED, which the two fixed points cannot, so clear reviewed records + # from the frontier of the cycle upward. + changed = True + while changed: + changed = False + for entry in graded: + if entry["id"] in circular and record_level(entry, level) == CLEAN: + level[entry["id"]] = CLEAN + circular.discard(entry["id"]) + changed = True + + result: dict[str, dict[str, Any]] = {} + for entry in graded: + status = level[entry["id"]] + owed: list[dict[str, str]] = [] + lost: list[dict[str, str]] = [] + sets = [ + [(cited, *ground(entry["id"], cited, level)) for cited in group] + for group in entry["supports"] + ] + for values in sets: + worst = min(value for _, value, _, _ in values) + for cited, value, head, because in values: + if status == FLAGGED and worst == FLAGGED and value == FLAGGED: + item = {"ground": cited, "head": head, "because": because} + if item not in owed: + owed.append(item) + if status == UNSUPPORTED and value == UNSUPPORTED: + item = {"ground": cited, "because": because} + if item not in lost: + lost.append(item) + if status == FLAGGED: + for cited, head in prerequisites(entry): + item = {"ground": cited, "head": head, "because": "revise"} + if item not in owed: + owed.append(item) + result[entry["id"]] = {"support": NAMES[status], "review_owed": owed, "lost_grounds": lost} + return result diff --git a/docket/where.py b/docket/where.py index 125516c..f2da4f6 100644 --- a/docket/where.py +++ b/docket/where.py @@ -14,12 +14,13 @@ from datetime import date, datetime, timezone from typing import Any +from docket import support from docket.config import DEFAULTS from docket.context_model import _list, _normalize_path, scope_strength from docket.ledger import KINDS, STATES FIELDS = ("after", "author", "before", "branch", "is", "kind", "scope", "state") -IS_VALUES = ("blocked", "corrected", "pinned", "retired") +IS_VALUES = ("blocked", "corrected", "flagged", "pinned", "retired", "unsupported") STATE_VALUES = tuple(sorted({state for values in STATES.values() for state in values})) _TEXT_KEYS = ("id", "text", "choice", "rationale") _FIELD_RE = re.compile(r"[A-Za-z]+") @@ -169,6 +170,8 @@ def _hit(term: Term, entry: Mapping[str, Any]) -> bool: def _is(value: str, entry: Mapping[str, Any]) -> bool: + if value in ("flagged", "unsupported"): + return support.surfaced(entry, value) if value == "pinned": return bool(entry.get("pinned")) if value == "corrected": diff --git a/docs/commands.md b/docs/commands.md index fc82ad2..af9e278 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -41,14 +41,16 @@ Ledger commands use the file that `docket where` reports. Run | `--depends-on IDS` | decision | Claims or decisions required for this decision to apply | | `--answers IDS` | claim, decision | Questions this record settles | | `--supersedes IDS` | all | Same-kind records this one retires | +| `--supersede-reason R` | all | `restate`, `revise`, or `reverse`. Refused without `--supersedes`. Omitting it with `--supersedes` prints a hint and reads as `revise`. | | `--pin` | all | Add a ranking bonus in briefings; inclusion is not guaranteed | Every ID flag takes a comma-separated list. Repeat `--supports` for alternative sets of grounds: `--supports c1,c2 --supports c3` means `(c1 AND c2) OR c3`. Repeat `--scope`, `--evidence`, or `--alternative` for more than one value. -A decision prerequisite must be current, adopted, and applicable. A claim -prerequisite must be current and accepted. A decision with missing prerequisites +A prerequisite is followed through restatements and revisions to the head of +its chain. That head must be adopted and applicable for a decision, or accepted +for a claim. A reversal, or a rejected or revoked head, blocks. A decision with missing prerequisites remains recorded as adopted but reports that it is blocked. See the [relationship reference](ledger.md#relations). @@ -64,6 +66,7 @@ remains recorded as adopted but reports that it is blocked. See the | `--alternative A` | Decision only. Replace the alternatives list. Repeat for more. | | `--decided-by WHO` | Decision only. Replace who made the call. | | `--clear scope\|evidence\|alternatives` | Empty a list instead of replacing it. Repeat for more than one field. | +| `--supersede-reason R` | Relabel a supersession as `restate`, `revise`, or `reverse`. The record must have `supersedes`. | | `--reason R` | Why the record was wrong | Correct a record's wording or metadata. The record keeps its ID, and a @@ -72,6 +75,10 @@ repeated flag replaces the whole list it names. The command refuses to change those. `docket show ID` lists a record's corrections, and `docket show ID.N` prints one correction with the values it replaced. +`docket review ID [--note TEXT]` + +Record that you checked a claim or decision whose grounds changed. It appends a review line pinning each of the record's own revised, unassessed, disputed or circular grounds to that ground's current head. A flag inherited from a flagged or blocked ground is not pinned; it clears by reviewing or fixing that ground, so review the frontier first. The command refuses with "nothing to review" when no ground is owed, and with "its flags come from ..." when only inherited flags remain. A later revision of the same chain raises the flag again. See [review lines](ledger.md#review-lines). + ## Reading `docket list` @@ -109,6 +116,18 @@ show ID.N` prints one correction with the values it replaced. | `--detail N` | With `--format`, characters of text per node. Default 40, `0` for IDs alone. | | `--direction LR\|TD\|RL\|BT` | With `--format mermaid` or `dot`, the layout direction. Default `LR`. | +Claims and decisions carry three derived fields in `--json` output and in `show`: + +| Field | Meaning | +|---|---| +| `support` | `clean`, `flagged` (a ground changed or is unsettled and the record has not been reviewed against it), or `unsupported` (no complete set of grounds survives) | +| `review_owed` | a list of `{ground, head, because}`, one per ground to review; `docket review` clears them | +| `lost_grounds` | a list of `{ground, because}` for grounds an unsupported record lost | + +`context` prints `review_owed:` and `lost:` lines under an affected record and ends with `# owe review: N; docket list --where is:flagged`. `context --since` adds ", N newly owe review" to its summary and counts a newly unsupported record as no longer available. `show` prints "Review owed" and "Lost grounds" sections, and the text `graph` marks records `? review` or `! lost`. + +A decision's `depends_on` follows restatements and revisions to the head of the chain. A reversal, or a rejected or revoked head, blocks the decision; a revised prerequisite flags it. An unsupported prerequisite does not block. + ### Query language `--where` takes one query. The graph viewer's `/` input takes the same one. @@ -123,6 +142,7 @@ show ID.N` prints one correction with the values it replaced. | `scope:PATH` | a scope entry governs the file PATH, as `docket context --file` scores it | | `scope:DIR/` | a scope entry starts with `DIR/`, or governs the directory | | `is:pinned`, `is:corrected`, `is:retired`, `is:blocked` | the record is pinned, corrected, or retired, or is a blocked decision | +| `is:flagged`, `is:unsupported` | the record is a current accepted claim or adopted decision whose derived `support` is `flagged` or `unsupported` | | `author:A`, `branch:B` | the author or the branch contains the value | | `after:D`, `before:D` | the record's UTC date is on or after D, or before D; D is `YYYY-MM-DD` | diff --git a/docs/definitions.md b/docs/definitions.md index f68b2a7..ac27bb2 100644 --- a/docs/definitions.md +++ b/docs/definitions.md @@ -271,7 +271,7 @@ predecessor. those records permanently in derived current views without deleting or rewriting their history. Supersession does not revoke dependents automatically. -**Supersession reason.** Specified, not yet implemented. Each supersession carries a reason, and the reason decides what a record that cites the retired one keeps. A citation resolves forward to the head of the supersession chain, which is unique because a retired record cannot be superseded again. +**Supersession reason.** Each supersession carries a reason, and the reason decides what a record that cites the retired one keeps. A citation resolves forward to the head of the supersession chain, which is unique because a retired record cannot be superseded again. | Reason | Use it when | A dependent that cites the retired record | | --- | --- | --- | @@ -279,6 +279,14 @@ their history. Supersession does not revoke dependents automatically. | `revise` | the substance changed | holds through the head and is flagged for review | | `reverse` | the retired record was wrong | loses that ground, and holds only through another complete justification set | +A cited record's state counts too: `unassessed`, `disputed`, or a blocked decision flags the citing record; `rejected` or `revoked` removes the ground. `docket review ID` acknowledges the record's own revised, unassessed, disputed or circular grounds, each against the head it resolved to. + +A record whose support is circular and never grounded is flagged with `because: circular`. Forward resolution can create such a cycle: the least fixed point says the record does not hold, and the greatest fixed point says nothing withdrew it. A reversal, rejection or revocation inside the cycle still removes the ground. `docket review ID` clears a circular flag, and records that cite the cleared one become clean with it. + +A flag inherited from a ground that is itself flagged (`because: flagged`) or blocked (`because: blocked`) describes a problem at that ground. It clears when that ground is reviewed or fixed, never by a review of the citing record, and a stored pin naming such a ground is ignored. A review of a record's own ground comes back only if the chain head the review pinned moves. + +A prerequisite cycle created by forward resolution blocks the decision (least fixed point), unlike a `supports` cycle, which is flagged `circular`. + A missing reason reads as `revise`, so omitting it costs a review flag and never hides one. Use `docket correct` instead when the record's id should stay and nothing but a correctable field changes. The Lean proofs check these three outcomes and show that they reduce to the plain retention rule when nothing is superseded. ### Applicability and action gates @@ -287,9 +295,10 @@ The action gate described in the research documents is future behavior. The ledger records typed states and relationships; it does not enforce an `allow`/`deny`/`ask` mapping. -An adopted decision is **applicable** when its `depends_on` claims are accepted -and current and its `depends_on` decisions are adopted, current, and themselves -applicable. Otherwise a derived view exposes the unavailable prerequisite IDs +An adopted decision is **applicable** when each of its prerequisites, followed +through restatements and revisions to the head of its chain, resolves to an +accepted claim or an adopted, applicable decision. A reversal, or a rejected or +revoked head, blocks. Otherwise a derived view exposes the unavailable prerequisite IDs in `blocked_by`. This is a derived usability result. It does not revoke the recorded choice or propagate truth. diff --git a/docs/ledger.md b/docs/ledger.md index 0977232..f118a5e 100644 --- a/docs/ledger.md +++ b/docs/ledger.md @@ -98,6 +98,9 @@ when their CLI options are omitted, and sets `pinned` to `false`: resolves its target only when the source is current and is either an accepted claim or an applicable adopted decision. - `supersedes`: same-kind records retired by this record. +- `supersede_reason`: optional, `restate`, `revise`, or `reverse`; valid only + with `supersedes`. Absent reads as `revise`. See + [Supersession reason](definitions.md). - `evidence`: provenance objects attached to the record. - `revisit`: a note about when or why to revisit the record. - `cost_if_wrong`: the stated cost if the record is wrong. @@ -145,6 +148,26 @@ corrections in file order; `docket show ID --json` carries `corrections` and Docket releases from before corrections were introduced cannot read a ledger containing a correction line. +## Review lines + +A review line records that someone checked a record against the current head of each ground it owns a flag for: a revised, unassessed, disputed or circular ground. It clears the flag for exactly those heads, so a later revision of the same chain raises the flag again. A flag inherited from a ground that is itself flagged or blocked is not the reviewed record's to clear; it clears by reviewing or fixing that ground, and a stored pin that names such a ground is ignored. + + {"schema":2,"kind":"review","id":"d72.r1","reviews":"d72", + "grounds":{"d21":"d93"}, + "note":"d93 tightened the refusal rules; the docs-style choice still stands", + "ts":"...","author":"...","session":"...","branch":"..."} + +| Field | Meaning | +|---|---| +| `id` | `.r`; `n` counts that record's reviews from 1 and must increase, gaps allowed | +| `reviews` | the record id; must equal the id's base and name an earlier record | +| `grounds` | a non-empty object from each reviewed ground id to the head id it resolved to when reviewed; both must be earlier records | +| `note` | what the reviewer concluded; may be empty | + +A review line carries no other keys, no `supersedes`, and nothing may point at it. `docket review` refuses a record that owes nothing, or owes only inherited flags. Reading checks only shape and references, so a review stays readable after the ledger moves on. Commands fold review lines into each record as a derived `reviews` list. + +Docket 0.20.x and older cannot read a ledger that contains a `supersede_reason`, a correction of one, or a review line. Upgrade every machine and plugin cache that reads the ledger before the first such line is written. + ## Relations ### Supports @@ -165,12 +188,12 @@ Only claims and decisions can be support targets. `depends_on` is an operational relation for decisions. Its targets are claims or decisions, and it has no OR interpretation. -A current adopted decision is applicable when all of these conditions hold: +A prerequisite is followed through restatements and revisions to the head of its chain. A current adopted decision is applicable when all of these conditions hold: -- claim prerequisites are accepted and current; -- decision prerequisites are adopted, current, and applicable. +- the head of each claim prerequisite is accepted; +- the head of each decision prerequisite is adopted and applicable. -Otherwise the projected record contains `applicable: false` and `blocked_by` +A reversal, or a rejected or revoked head, blocks the decision. Otherwise the projected record contains `applicable: false` and `blocked_by` with the unavailable prerequisites. Its recorded choice remains adopted, and a blocked decision does not resolve a question. @@ -190,7 +213,8 @@ The validator preserves the original record. `project` adds: - `recorded_state`; - effective `state` for resolved questions; - `retired_by` and `resolved_by`; -- `applicable` and `blocked_by` for decisions. +- `applicable` and `blocked_by` for decisions; +- `support`, `review_owed`, `lost_grounds` and `reviews` for claims and decisions. Retiring a record preserves its recorded state and the recorded states of dependent decisions. Their applicability is recalculated, and any older diff --git a/experiments/lean-outcomes/STRESS-TESTS.md b/experiments/lean-outcomes/STRESS-TESTS.md index 7c86d59..5c3f5dd 100644 --- a/experiments/lean-outcomes/STRESS-TESTS.md +++ b/experiments/lean-outcomes/STRESS-TESTS.md @@ -161,6 +161,8 @@ One model checks each reason on a dependent that cites a superseded premise. Und The reason is self-reported, so a real change recorded as `restate` passes without review. Docket therefore treats a missing reason as `revise`: omitting it costs a flag and never hides one. +The Lean readings are least fixed points, so circular support is never clean, and the proofs check that. Forward resolution can create such cycles in a real ledger, and the implementation reports the difference between the least and greatest fixed points as flagged rather than lost: a record that no ground supports and nothing withdrew owes review instead of vanishing. + ## Implications for the paper The central reduction survives as a clean representation result. The claims diff --git a/skills/docket/SKILL.md b/skills/docket/SKILL.md index 69d7402..623a71d 100644 --- a/skills/docket/SKILL.md +++ b/skills/docket/SKILL.md @@ -83,9 +83,10 @@ This records `(c1 AND c2) OR c3`. It is not a verified implication and does not propagate truth. References must name earlier claim or decision records. Use `--depends-on` only on decisions for operational prerequisites. It is -separate from support alternatives. An adopted decision is applicable when its -claim prerequisites are accepted and current and its decision prerequisites are -adopted, current, and applicable. A derived decision can remain recorded as +separate from support alternatives. A prerequisite is followed through +restatements and revisions to the head of its chain; the head must be accepted +(claim) or adopted and applicable (decision), and a reversal, or a rejected or +revoked head, blocks. A derived decision can remain recorded as `adopted` while reporting `blocked_by`; a blocked decision does not answer a question. @@ -102,6 +103,8 @@ unknown, later, or self references. It enforces relation target kinds. Duplicate record IDs, cross-kind supersession, and supersession of an already retired record are rejected. +When superseding, pass `--supersede-reason restate` for wording or link changes, `revise` for a change of substance, and `reverse` when the old record was wrong. Records that cite the retired one follow it to the new head, and the reason decides whether they stay clean, owe review, or lose the ground. After you check a record listed by `docket list --where is:flagged`, run `docket review ID`. Review the frontier first: records whose `review_owed` items are not `because: flagged` or `blocked`. A review acknowledges only the record's own revised, unassessed, disputed or circular grounds; a flag inherited from a flagged or blocked ground clears when you review or fix that ground, and `docket review` refuses those records. + The common options are `--scope` (repeatable), `--rationale`, `--supports`, `--depends-on`, `--answers`, `--supersedes`, `--evidence` (repeatable), `--revisit`, `--cost`, and `--pin`. diff --git a/tests/test_completion.py b/tests/test_completion.py index c5bc265..87db47d 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -6,7 +6,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) -from docket.cli.completion import _BASH_COMPLETION +from docket.cli.completion import _BASH_COMPLETION, _FISH_COMPLETION @unittest.skipUnless(shutil.which("bash"), "bash is not installed") @@ -64,5 +64,13 @@ def test_the_top_level_commands_still_complete(self): self.assertIn("feature", offered) +class FishCompletionTests(unittest.TestCase): + def test_every_condition_is_closed_before_the_next_option(self): + lines = [line for line in _FISH_COMPLETION.splitlines() if '-n "' in line] + self.assertTrue(lines) + for line in lines: + self.assertRegex(line, r'-n "[^"]*"(\s|$)') + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_context.py b/tests/test_context.py index 768e1f9..78a65f3 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -27,8 +27,10 @@ def entry( cost_if_wrong="", decided_by="", supersedes=(), + supersede_reason=None, ): kwargs = dict( + supersede_reason=supersede_reason, state=state, scope=list(scope), rationale=rationale, @@ -549,7 +551,14 @@ def test_blocking_path_names_the_chain_and_the_reason(self): def test_blocking_path_names_retirement_rather_than_recorded_state(self): records = [ entry("c1", "claim", "Superseded premise", state="accepted"), - entry("c2", "claim", "Replacement", state="accepted", supersedes=("c1",)), + entry( + "c2", + "claim", + "Replacement", + state="accepted", + supersedes=("c1",), + supersede_reason="reverse", + ), entry( "d3", "decision", @@ -561,8 +570,8 @@ def test_blocking_path_names_retirement_rather_than_recorded_state(self): ] rendered = build_context(projected(records), files=("lib/cache.py",), ledger="repo") # c1's effective state is still "accepted"; the reason it cannot be used - # is that c2 retired it. - self.assertIn("blocked: c1 retired", rendered) + # is that c2 reversed it. + self.assertIn("blocked: c1 reversed", rendered) def test_blocking_path_stops_on_a_cycle(self): records = [ @@ -866,7 +875,14 @@ def test_delta_names_added_and_newly_unavailable_records(self): records = [ entry("c1", "claim", "The cache is reliable", state="accepted"), entry("d2", "decision", "Serve from the cache", choice="serve", depends_on=("c1",)), - entry("c3", "claim", "Replace the premise", state="accepted", supersedes=("c1",)), + entry( + "c3", + "claim", + "Replace the premise", + state="accepted", + supersedes=("c1",), + supersede_reason="reverse", + ), ] delta = build_delta( projected(records), since="d2", baseline=projected(records[:2]), ledger="repo" @@ -972,7 +988,14 @@ def test_the_three_delta_lists_stay_disjoint(self): raw = [ entry("c1", "claim", "A premise", state="accepted"), entry("d2", "decision", "Serve from the cache", choice="serve", depends_on=("c1",)), - entry("c3", "claim", "Replace the premise", state="accepted", supersedes=("c1",)), + entry( + "c3", + "claim", + "Replace the premise", + state="accepted", + supersedes=("c1",), + supersede_reason="reverse", + ), self.correction("c1.1", "c1", {"rationale": "Measured."}), self.correction("c3.1", "c3", {"rationale": "Measured."}), ] diff --git a/tests/test_correct_cli.py b/tests/test_correct_cli.py index 3095e4a..d367186 100644 --- a/tests/test_correct_cli.py +++ b/tests/test_correct_cli.py @@ -179,7 +179,10 @@ def test_context_since_a_correction_token_reports_no_change(self): token = re.search(r"latest: (d1\.1@[0-9a-f]+)", out.stdout).group(1) out = run(self.cwd, "context", "--no-auto-scope", "--since", token) self.assertEqual(out.returncode, 0, out.stderr) - self.assertIn("# changed: 0 added, 0 corrected, 0 no longer available.", out.stdout) + self.assertIn( + "# changed: 0 added, 0 corrected, 0 no longer available, 0 newly owe review.", + out.stdout, + ) class CheckTests(unittest.TestCase): diff --git a/tests/test_docket.py b/tests/test_docket.py index 204eeb2..4ac832a 100644 --- a/tests/test_docket.py +++ b/tests/test_docket.py @@ -600,6 +600,39 @@ def test_since_builds_the_baseline_from_file_position_not_id_number(self): self.assertIn("### c8 ", stdout) self.assertIn("1 added, 0 corrected, 1 no longer available", stdout) + def test_a_record_that_changed_and_newly_owes_review_prints_once(self): + with tempfile.TemporaryDirectory() as home: + for args in ( + ("claim", "Ground claim one holds", "--state", "accepted"), + ("claim", "Prerequisite claim two holds", "--state", "accepted"), + ( + "decision", + "We commit to option three for the stated reasons", + "--choice", + "opt three", + "--supports", + "c1", + "--depends-on", + "c2", + "--rationale", + "r", + ), + ): + self.assertEqual(run(home, *args).returncode, 0) + first = run(home, "context", "--all").stdout.splitlines()[0] + token = first.split("latest: ")[1].split()[0] + for args in ( + ("claim", "Prerequisite claim two was wrong", "--state", "accepted") + + ("--supersedes", "c2", "--supersede-reason", "reverse"), + ("claim", "Ground claim one changed", "--state", "accepted") + + ("--supersedes", "c1", "--supersede-reason", "revise"), + ): + self.assertEqual(run(home, *args).returncode, 0) + result = run(home, "context", "--since", token) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.count("### d3 "), 1) + self.assertIn("3 no longer available, 0 newly owe review", result.stdout) + class InitTests(unittest.TestCase): def test_init_ignores_the_lock_file(self): diff --git a/tests/test_review_cli.py b/tests/test_review_cli.py new file mode 100644 index 0000000..ec47792 --- /dev/null +++ b/tests/test_review_cli.py @@ -0,0 +1,153 @@ +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +DOCKET = str(Path(__file__).resolve().parent.parent / "bin" / "docket") + + +def run(cwd, *args): + env = dict(os.environ) + env["DOCKET_HOME"] = str(Path(cwd) / "global") + env["DOCKET_AUTHOR"] = "test" + env["DOCKET_NO_UPDATE_CHECK"] = "1" + env["XDG_STATE_HOME"] = str(Path(cwd) / "state") + return subprocess.run( + [sys.executable, DOCKET, *args], cwd=cwd, env=env, capture_output=True, text=True + ) + + +class SupersedeReasonCommandTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.cwd = self.tmp.name + for args in ( + ("claim", "Writes are durable.", "--state", "accepted"), + ("claim", "The queue is safe.", "--state", "accepted", "--supports", "c1"), + ): + out = run(self.cwd, *args) + self.assertEqual(out.returncode, 0, out.stderr) + + def tearDown(self): + self.tmp.cleanup() + + def show(self, ident): + out = run(self.cwd, "show", ident, "--json") + self.assertEqual(out.returncode, 0, out.stderr) + return json.loads(out.stdout) + + def supersede(self, *extra): + return run( + self.cwd, + "claim", + "Writes are durable after fsync.", + "--state", + "accepted", + "--supersedes", + "c1", + *extra, + ) + + def test_a_reason_is_recorded(self): + out = self.supersede("--supersede-reason", "restate") + self.assertEqual(out.returncode, 0, out.stderr) + self.assertEqual(self.show("c3")["supersede_reason"], "restate") + self.assertEqual(self.show("c2")["support"], "clean") + + def test_a_missing_reason_hints_and_flags(self): + out = self.supersede() + self.assertEqual(out.returncode, 0, out.stderr) + self.assertIn("recorded as revise", out.stderr) + self.assertEqual(self.show("c2")["support"], "flagged") + + def test_a_superseding_question_gets_no_hint(self): + self.assertEqual(run(self.cwd, "question", "Is it durable?").returncode, 0) + out = run(self.cwd, "question", "Is it durable on ext4?", "--supersedes", "q3") + self.assertEqual(out.returncode, 0, out.stderr) + self.assertNotIn("recorded as revise", out.stderr) + + def test_a_reason_without_supersedes_is_refused(self): + out = run(self.cwd, "claim", "Another claim.", "--supersede-reason", "restate") + self.assertEqual(out.returncode, 1) + self.assertIn("--supersede-reason needs --supersedes", out.stderr) + + def test_review_clears_the_flag(self): + self.supersede() + out = run(self.cwd, "review", "c2", "--note", "fsync wording only") + self.assertEqual(out.returncode, 0, out.stderr) + self.assertIn("c2.r1", out.stdout) + self.assertEqual(self.show("c2")["support"], "clean") + + def test_check_counts_reviews_apart_from_corrections(self): + self.supersede() + run(self.cwd, "review", "c2") + out = run(self.cwd, "check") + self.assertEqual(out.returncode, 0, out.stdout) + self.assertIn("3 records and 1 review", out.stdout) + self.assertNotIn("correction", out.stdout) + + def test_review_of_nothing_is_refused(self): + before = (Path(self.cwd) / "global").rglob("ledger.jsonl") + ledger_path = next(before) + lines = ledger_path.read_text().count("\n") + out = run(self.cwd, "review", "c2") + self.assertEqual(out.returncode, 1) + self.assertIn("nothing to review", out.stderr) + self.assertEqual(ledger_path.read_text().count("\n"), lines) + + def ledger_path(self): + return next((Path(self.cwd) / "global").rglob("ledger.jsonl")) + + def test_a_record_owing_only_flagged_grounds_is_refused(self): + run(self.cwd, "claim", "The service is safe.", "--state", "accepted", "--supports", "c2") + self.supersede() + path = self.ledger_path() + before = path.read_text() + out = run(self.cwd, "review", "c3") + self.assertEqual(out.returncode, 1) + self.assertIn( + "nothing to review on c3: its flags come from c2; review or fix those", out.stderr + ) + self.assertEqual(path.read_text(), before) + + def test_review_pins_only_the_grounds_the_record_owns(self): + run(self.cwd, "claim", "Replication is on.", "--state", "accepted") + run(self.cwd, "claim", "The service is safe.", "--state", "accepted", "--supports", "c2") + out = run( + self.cwd, "claim", "Failover works.", "--state", "accepted", "--supports", "c3,c4" + ) + self.assertEqual(out.returncode, 0, out.stderr) + out = run( + self.cwd, + "claim", + "Replication is on in two regions.", + "--state", + "accepted", + "--supersedes", + "c3", + "--supersede-reason", + "revise", + ) + self.assertEqual(out.returncode, 0, out.stderr) + self.supersede() + out = run(self.cwd, "review", "c5") + self.assertEqual(out.returncode, 0, out.stderr) + line = json.loads(self.ledger_path().read_text().splitlines()[-1]) + self.assertEqual(line["grounds"], {"c3": "c6"}) + + def test_review_of_an_unknown_id_is_refused(self): + out = run(self.cwd, "review", "c9") + self.assertEqual(out.returncode, 1) + + def test_correct_relabels_the_reason(self): + self.supersede() + out = run(self.cwd, "correct", "c3", "--supersede-reason", "restate", "--reason", "wording") + self.assertEqual(out.returncode, 0, out.stderr) + self.assertEqual(self.show("c2")["support"], "clean") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_reviews.py b/tests/test_reviews.py new file mode 100644 index 0000000..8c34e5c --- /dev/null +++ b/tests/test_reviews.py @@ -0,0 +1,110 @@ +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +import docket.ledger as ledger +from docket import rebase, reviews + + +def claim(ident, **kwargs): + kwargs.setdefault("state", "accepted") + return ledger.make_record( + "claim", f"Claim {ident} holds.", author="test", record_id=ident, **kwargs + ) + + +def review(ident, target, grounds, note=""): + return { + "schema": 2, + "kind": "review", + "id": ident, + "reviews": target, + "grounds": grounds, + "note": note, + "ts": "2026-10-02T00:00:00+00:00", + "author": "test", + "session": "", + "branch": "", + } + + +BASE = [claim("c1"), claim("c2", supports=[["c1"]]), claim("c3", supersedes=["c1"])] + + +class ReviewValidationTests(unittest.TestCase): + def test_a_review_reads(self): + entries = ledger.validate_entries([*BASE, review("c2.r1", "c2", {"c1": "c3"})]) + self.assertEqual(entries[-1]["id"], "c2.r1") + + def test_the_id_base_must_equal_reviews(self): + with self.assertRaisesRegex(ledger.LedgerError, "must start with"): + ledger.validate_entries([*BASE, review("c3.r1", "c2", {"c1": "c3"})]) + + def test_a_malformed_id_is_refused(self): + with self.assertRaisesRegex(ledger.LedgerError, r"\.r"): + ledger.validate_entries([*BASE, review("c2.r0", "c2", {"c1": "c3"})]) + + def test_grounds_must_be_a_nonempty_map_of_known_ids(self): + for grounds in ({}, {"c1": "c9"}, {"c9": "c3"}, {"c1": 3}): + with self.subTest(grounds=grounds): + with self.assertRaises(ledger.LedgerError): + ledger.validate_entries([*BASE, review("c2.r1", "c2", grounds)]) + + def test_numbers_increase_per_record(self): + with self.assertRaisesRegex(ledger.LedgerError, "must increase"): + ledger.validate_entries( + [*BASE, review("c2.r2", "c2", {"c1": "c3"}), review("c2.r1", "c2", {"c1": "c3"})] + ) + + def test_unknown_fields_are_refused(self): + line = review("c2.r1", "c2", {"c1": "c3"}) + line["extra"] = 1 + with self.assertRaisesRegex(ledger.LedgerError, "unknown field"): + ledger.validate_entries([*BASE, line]) + + def test_a_review_does_not_consume_a_record_number(self): + entries = ledger.validate_entries([*BASE, review("c2.r1", "c2", {"c1": "c3"})]) + self.assertEqual(ledger.next_id(entries), "4") + + +class ReviewFoldTests(unittest.TestCase): + def test_fold_attaches_reviews_and_drops_the_lines(self): + folded = reviews.fold( + ledger.validate_entries([*BASE, review("c2.r1", "c2", {"c1": "c3"}, note="fine")]) + ) + self.assertEqual([e["id"] for e in folded], ["c1", "c2", "c3"]) + self.assertEqual( + folded[1]["reviews"], [{"id": "c2.r1", "grounds": {"c1": "c3"}, "note": "fine"}] + ) + self.assertNotIn("reviews", folded[0]) + + +class ReviewAllocateTests(unittest.TestCase): + def test_allocate_numbers_per_record(self): + entries = [*BASE, review("c2.r1", "c2", {"c1": "c3"})] + self.assertEqual(reviews.allocate(entries, "c2"), "c2.r2") + self.assertEqual(reviews.allocate(entries, "c3"), "c3.r1") + + +class ReviewRebaseTests(unittest.TestCase): + def test_rebase_renumbers_review_lines(self): + with tempfile.TemporaryDirectory() as tmp: + mine = Path(tmp) / "mine.jsonl" + theirs = Path(tmp) / "theirs.jsonl" + for path, extra in ( + (mine, [claim("c4")]), + (theirs, [claim("c4", supports=[["c1"]]), review("c4.r1", "c4", {"c1": "c3"})]), + ): + for record in [*BASE, *extra]: + ledger.append(path, record) + tail, mapping = rebase.renumber(ledger.read(mine), ledger.read(theirs)) + self.assertEqual(mapping["c4"], "c5") + line = tail[-1] + self.assertEqual((line["id"], line["reviews"]), ("c5.r1", "c5")) + self.assertEqual(line["grounds"], {"c1": "c3"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_supersede_reason.py b/tests/test_supersede_reason.py new file mode 100644 index 0000000..6d67205 --- /dev/null +++ b/tests/test_supersede_reason.py @@ -0,0 +1,71 @@ +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +import docket.ledger as ledger + + +def claim(ident, **kwargs): + kwargs.setdefault("state", "accepted") + return ledger.make_record( + "claim", f"Claim {ident} holds.", author="test", record_id=ident, **kwargs + ) + + +def correction(ident, target, fields): + return { + "schema": 2, + "kind": "correction", + "id": ident, + "corrects": target, + "fields": fields, + "reason": "", + "ts": "2026-10-02T00:00:00+00:00", + "author": "test", + "session": "", + "branch": "", + } + + +class SupersedeReasonTests(unittest.TestCase): + def test_a_reason_is_recorded_with_supersedes(self): + entries = ledger.validate_entries( + [claim("c1"), claim("c2", supersedes=["c1"], supersede_reason="restate")] + ) + self.assertEqual(entries[1]["supersede_reason"], "restate") + + def test_no_reason_leaves_the_field_absent(self): + entries = ledger.validate_entries([claim("c1"), claim("c2", supersedes=["c1"])]) + self.assertNotIn("supersede_reason", entries[1]) + + def test_a_reason_without_supersedes_is_refused(self): + with self.assertRaisesRegex(ledger.LedgerError, "supersede_reason needs supersedes"): + claim("c1", supersede_reason="restate") + + def test_an_unknown_reason_is_refused(self): + with self.assertRaisesRegex(ledger.LedgerError, "supersede_reason must be one of"): + ledger.validate_entries( + [claim("c1"), claim("c2", supersedes=["c1"], supersede_reason="rewrite")] + ) + + def test_a_correction_can_relabel_the_reason(self): + entries = ledger.project( + [ + claim("c1"), + claim("c2", supersedes=["c1"]), + correction("c2.1", "c2", {"supersede_reason": "restate"}), + ] + ) + record = next(e for e in entries if e["id"] == "c2") + self.assertEqual(record["supersede_reason"], "restate") + + def test_a_correction_cannot_add_a_reason_without_supersedes(self): + with self.assertRaisesRegex(ledger.LedgerError, "supersede_reason needs supersedes"): + ledger.validate_entries( + [claim("c1"), correction("c1.1", "c1", {"supersede_reason": "restate"})] + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_support.py b/tests/test_support.py new file mode 100644 index 0000000..88eb49c --- /dev/null +++ b/tests/test_support.py @@ -0,0 +1,400 @@ +import random +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +import docket.ledger as ledger + + +def claim(ident, **kwargs): + kwargs.setdefault("state", "accepted") + return ledger.make_record( + "claim", f"Claim {ident} holds.", author="test", record_id=ident, **kwargs + ) + + +def decision(ident, **kwargs): + kwargs.setdefault("choice", f"Option {ident}") + return ledger.make_record( + "decision", f"Decision {ident} commits.", author="test", record_id=ident, **kwargs + ) + + +def review(ident, target, grounds): + return { + "schema": 2, + "kind": "review", + "id": ident, + "reviews": target, + "grounds": grounds, + "note": "", + "ts": "2026-10-02T00:00:00+00:00", + "author": "test", + "session": "", + "branch": "", + } + + +def at(entries, ident): + return next(e for e in ledger.project(entries) if e["id"] == ident) + + +def chain(reason): + extra = {} if reason is None else {"supersede_reason": reason} + return [claim("c1"), claim("c2", supports=[["c1"]]), claim("c3", supersedes=["c1"], **extra)] + + +class ReasonTests(unittest.TestCase): + def test_a_premise_is_clean(self): + record = at([claim("c1")], "c1") + self.assertEqual( + (record["support"], record["review_owed"], record["lost_grounds"]), ("clean", [], []) + ) + + def test_restate_keeps_a_dependent_clean(self): + self.assertEqual(at(chain("restate"), "c2")["support"], "clean") + + def test_revise_flags_a_dependent(self): + record = at(chain("revise"), "c2") + self.assertEqual(record["support"], "flagged") + self.assertEqual( + record["review_owed"], [{"ground": "c1", "head": "c3", "because": "revise"}] + ) + + def test_a_missing_reason_reads_as_revise(self): + self.assertEqual(at(chain(None), "c2")["support"], "flagged") + + def test_reverse_removes_the_ground(self): + record = at(chain("reverse"), "c2") + self.assertEqual(record["support"], "unsupported") + self.assertEqual(record["lost_grounds"], [{"ground": "c1", "because": "reverse"}]) + + def test_an_alternative_survives_a_reversal(self): + entries = [ + claim("c1"), + claim("c2"), + claim("c3", supports=[["c1"], ["c2"]]), + claim("c4", supersedes=["c1"], supersede_reason="reverse"), + ] + self.assertEqual(at(entries, "c3")["support"], "clean") + + +class StateTests(unittest.TestCase): + def ground(self, cited): + return at([cited, claim("c2", supports=[[cited["id"]]])], "c2") + + def test_unassessed_and_disputed_grounds_flag(self): + for state in ("unassessed", "disputed"): + with self.subTest(state=state): + record = self.ground(claim("c1", state=state)) + self.assertEqual(record["support"], "flagged") + self.assertEqual(record["review_owed"][0]["because"], state) + + def test_a_rejected_ground_is_lost(self): + record = self.ground(claim("c1", state="rejected")) + self.assertEqual(record["lost_grounds"], [{"ground": "c1", "because": "rejected"}]) + + def test_a_revoked_ground_is_lost(self): + entries = [decision("d1", state="revoked"), claim("c2", supports=[["d1"]])] + self.assertEqual( + at(entries, "c2")["lost_grounds"], [{"ground": "d1", "because": "revoked"}] + ) + + def test_a_blocked_decision_flags(self): + entries = [ + claim("c1", state="unassessed"), + decision("d2", depends_on=["c1"]), + claim("c3", supports=[["d2"]]), + ] + self.assertEqual(at(entries, "c3")["review_owed"][0]["because"], "blocked") + + +class PropagationTests(unittest.TestCase): + def transitive(self): + return [ + claim("c1"), + claim("c2", supports=[["c1"]]), + claim("c3", supports=[["c2"]]), + claim("c4", supersedes=["c1"], supersede_reason="revise"), + ] + + def test_a_flag_propagates_upward(self): + record = at(self.transitive(), "c3") + self.assertEqual( + record["review_owed"], [{"ground": "c2", "head": "c2", "because": "flagged"}] + ) + + def test_reviewing_the_frontier_clears_its_dependents(self): + entries = [*self.transitive(), review("c2.r1", "c2", {"c1": "c4"})] + self.assertEqual([at(entries, i)["support"] for i in ("c2", "c3")], ["clean", "clean"]) + + def test_review_reraises_when_head_moves(self): + entries = [ + *chain("revise"), + review("c2.r1", "c2", {"c1": "c3"}), + claim("c5", supersedes=["c3"], supersede_reason="revise"), + ] + record = at(entries, "c2") + self.assertEqual( + record["review_owed"], [{"ground": "c1", "head": "c5", "because": "revise"}] + ) + + def test_a_review_from_above_never_waives_a_later_flag_of_the_ground(self): + entries = [ + *self.transitive(), + review("c3.r1", "c3", {"c2": "c2"}), + review("c2.r1", "c2", {"c1": "c4"}), + claim("c5", supersedes=["c4"], supersede_reason="revise"), + ] + c2, c3 = at(entries, "c2"), at(entries, "c3") + self.assertEqual(c2["review_owed"], [{"ground": "c1", "head": "c5", "because": "revise"}]) + self.assertEqual(c3["support"], "flagged") + self.assertEqual(c3["review_owed"], [{"ground": "c2", "head": "c2", "because": "flagged"}]) + + def test_a_review_from_above_never_waives_a_later_block_of_the_ground(self): + entries = [ + claim("c1"), + claim("c2"), + decision("d3", depends_on=["c2"], supports=[["c1"]]), + claim("c4", supports=[["d3"]]), + claim("c5", supersedes=["c1"], supersede_reason="revise"), + review("c4.r1", "c4", {"d3": "d3"}), + review("d3.r1", "d3", {"c1": "c5"}), + claim("c6", supersedes=["c2"], supersede_reason="reverse"), + ] + self.assertFalse(at(entries, "d3")["applicable"]) + c4 = at(entries, "c4") + self.assertEqual(c4["support"], "flagged") + self.assertEqual(c4["review_owed"], [{"ground": "d3", "head": "d3", "because": "blocked"}]) + + def test_a_pin_on_a_revised_ground_does_not_lift_the_flag_its_head_carries(self): + entries = [ + claim("c1"), + claim("c2"), + claim("c3", supports=[["c2"]]), + claim("c4", supports=[["c1"]], supersedes=["c2"], supersede_reason="revise"), + review("c3.r1", "c3", {"c2": "c4"}), + claim("c5", supersedes=["c1"], supersede_reason="revise"), + ] + c3 = at(entries, "c3") + self.assertEqual(c3["support"], "flagged") + self.assertEqual(c3["review_owed"], [{"ground": "c2", "head": "c4", "because": "flagged"}]) + + def test_a_pin_on_a_revised_ground_does_not_lift_the_block_its_head_carries(self): + entries = [ + claim("c1"), + decision("d2"), + claim("c3", supports=[["d2"]]), + decision("d4", depends_on=["c1"], supersedes=["d2"], supersede_reason="revise"), + review("c3.r1", "c3", {"d2": "d4"}), + claim("c5", supersedes=["c1"], supersede_reason="reverse"), + ] + self.assertFalse(at(entries, "d4")["applicable"]) + c3 = at(entries, "c3") + self.assertEqual(c3["support"], "flagged") + self.assertEqual(c3["review_owed"], [{"ground": "d2", "head": "d4", "because": "blocked"}]) + + def test_a_pin_on_a_circular_head_does_not_lift_a_block_the_head_also_carries(self): + entries = [ + claim("c1"), + claim("c2"), + decision("d3", depends_on=["c2"]), + claim("c4", supports=[["c1", "d3"]], supersedes=["c1"], supersede_reason="restate"), + decision("d5", supports=[["c1"]]), + claim("c6", state="unassessed"), + claim("c7", supports=[["d5"], ["c6"]]), + review("d5.r1", "d5", {"c1": "c4"}), + claim("c8", supersedes=["c2"], supersede_reason="reverse"), + ] + d5 = at(entries, "d5") + self.assertEqual(d5["support"], "flagged") + self.assertEqual(d5["review_owed"], [{"ground": "c1", "head": "c4", "because": "flagged"}]) + + def cycle(self, reason): + return [ + claim("c1"), + claim("c2", supports=[["c1"]]), + claim("c3", supports=[["c2"]], supersedes=["c1"], supersede_reason=reason), + ] + + def test_an_ungrounded_cycle_is_flagged_circular(self): + entries = self.cycle("restate") + self.assertEqual([at(entries, i)["support"] for i in ("c2", "c3")], ["flagged"] * 2) + self.assertIn( + {"ground": "c1", "head": "c3", "because": "circular"}, at(entries, "c2")["review_owed"] + ) + + def test_reviewing_the_cycle_frontier_clears_the_cycle(self): + entries = [*self.cycle("restate"), review("c2.r1", "c2", {"c1": "c3"})] + for ident in ("c2", "c3"): + record = at(entries, ident) + self.assertEqual((record["support"], record["review_owed"]), ("clean", [])) + + def test_a_partial_review_leaves_the_other_circular_ground_owed(self): + entries = [ + claim("c1"), + claim("c2"), + claim("c3", supports=[["c1", "c2"]]), + claim("c4", supports=[["c3"]], supersedes=["c1", "c2"], supersede_reason="restate"), + review("c3.r1", "c3", {"c1": "c4"}), + ] + record = at(entries, "c3") + self.assertEqual(record["support"], "flagged") + self.assertEqual( + record["review_owed"], [{"ground": "c2", "head": "c4", "because": "circular"}] + ) + + def test_the_repository_cycle_shape_is_flagged(self): + entries = [ + claim("c1", state="disputed"), + claim("c2", state="unassessed", supports=[["c1"]]), + claim("c3", supports=[["c2"]], supersedes=["c1"]), + decision("d4", supports=[["c1"]]), + ] + c3, d4 = at(entries, "c3"), at(entries, "d4") + self.assertEqual((c3["support"], d4["support"]), ("flagged", "flagged")) + self.assertEqual(d4["review_owed"], [{"ground": "c1", "head": "c3", "because": "circular"}]) + self.assertEqual((c3["lost_grounds"], d4["lost_grounds"]), ([], [])) + + def test_a_real_loss_inside_a_cycle_stays_unsupported(self): + record = at(self.cycle("reverse"), "c2") + self.assertEqual(record["support"], "unsupported") + self.assertEqual(record["lost_grounds"], [{"ground": "c1", "because": "reverse"}]) + + def test_a_cycle_member_holds_through_another_set(self): + entries = [ + claim("c1"), + claim("c2"), + claim("c3", supports=[["c1"], ["c2"]]), + claim("c4", supports=[["c3"]], supersedes=["c1"], supersede_reason="restate"), + ] + self.assertEqual(at(entries, "c3")["support"], "clean") + + def test_without_supersession_every_accepted_record_is_clean(self): + entries = [ + claim("c1"), + claim("c2", supports=[["c1"]]), + decision("d3", supports=[["c1", "c2"]]), + claim("c4", supports=[["d3"], ["c1"]]), + ] + self.assertEqual( + {e["support"] for e in ledger.project(entries) if "support" in e}, {"clean"} + ) + + +class PrerequisiteTests(unittest.TestCase): + def prerequisite(self, reason): + return [ + claim("c1"), + decision("d2", depends_on=["c1"]), + claim("c3", supersedes=["c1"], supersede_reason=reason), + ] + + def test_a_restated_prerequisite_is_followed(self): + record = at(self.prerequisite("restate"), "d2") + self.assertEqual((record["applicable"], record["support"]), (True, "clean")) + + def test_a_revised_prerequisite_is_followed_and_flagged(self): + record = at(self.prerequisite("revise"), "d2") + self.assertEqual((record["applicable"], record["support"]), (True, "flagged")) + self.assertEqual( + record["review_owed"], [{"ground": "c1", "head": "c3", "because": "revise"}] + ) + + def test_a_reversed_prerequisite_blocks(self): + record = at(self.prerequisite("reverse"), "d2") + self.assertFalse(record["applicable"]) + self.assertIn("c1", record["blocked_by"]) + + +class ForwardCycleTests(unittest.TestCase): + def test_a_prerequisite_that_resolves_back_onto_its_dependent_blocks(self): + entries = [ + decision("d1"), + decision("d2", depends_on=["d1"]), + decision("d3", depends_on=["d2"], supersedes=["d1"], supersede_reason="restate"), + ] + d2, d3 = at(entries, "d2"), at(entries, "d3") + self.assertEqual((d2["applicable"], d3["applicable"]), (False, False)) + self.assertIn("d1", d2["blocked_by"]) + self.assertEqual((d2["support"], d3["support"]), ("clean", "clean")) + + +def random_ledger(rng): + entries, retired = [], set() + for number in range(1, rng.randint(3, 12) + 1): + kind = rng.choice(["claim", "decision"]) + ident = f"{kind[0]}{number}" + earlier = [e["id"] for e in entries] + extra = {} + if earlier: + extra["supports"] = [ + sorted({rng.choice(earlier) for _ in range(rng.randint(1, 2))}) + for _ in range(rng.choice([0, 1, 1, 2])) + ] + if kind == "decision" and earlier and rng.random() < 0.3: + extra["depends_on"] = [rng.choice(earlier)] + open_same = [e["id"] for e in entries if e["kind"] == kind and e["id"] not in retired] + if open_same and rng.random() < 0.35: + target = rng.choice(open_same) + extra["supersedes"] = [target] + extra["supersede_reason"] = rng.choice(["restate", "revise", "reverse"]) + if kind == "claim": + extra["state"] = rng.choice( + ["accepted", "accepted", "unassessed", "disputed", "rejected"] + ) + record = claim(ident, **extra) + else: + extra["state"] = rng.choice(["adopted", "adopted", "revoked"]) + record = decision(ident, **extra) + try: + ledger.project([*entries, record]) + except ledger.LedgerError: + continue + entries.append(record) + retired.update(extra.get("supersedes", [])) + return entries + + +class InvariantTests(unittest.TestCase): + def test_random_ledgers_keep_support_consistent_with_its_evidence(self): + for seed in range(300): + rng = random.Random(seed) + entries = random_ledger(rng) + for round_ in range(rng.randint(0, 3)): + for record in ledger.project(entries): + if "review_owed" in record and record["review_owed"] and rng.random() < 0.5: + owed = {o["ground"]: o["head"] for o in record["review_owed"]} + number = sum(1 for e in entries if e["id"].startswith(record["id"] + ".r")) + entries.append(review(f"{record['id']}.r{number + 1}", record["id"], owed)) + with self.subTest(seed=seed): + projected = ledger.project(entries) + for record in projected: + if "support" not in record: + continue + owed, lost = record["review_owed"], record["lost_grounds"] + expected = { + "flagged": (True, False), + "unsupported": (False, True), + "clean": (False, False), + }[record["support"]] + self.assertEqual((bool(owed), bool(lost)), expected, record["id"]) + self.assertEqual(ledger.project(entries), projected) + + +class RevisionTests(unittest.TestCase): + def test_derived_support_fields_stay_out_of_the_revision(self): + from docket.context_model import _revision + + projected = ledger.project(chain("revise")) + self.assertIn("support", projected[1]) + bare = [ + {k: v for k, v in e.items() if k not in ("support", "review_owed", "lost_grounds")} + for e in projected + ] + self.assertEqual(_revision(projected), _revision(bare)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_support_smoke.py b/tests/test_support_smoke.py new file mode 100644 index 0000000..2f91f28 --- /dev/null +++ b/tests/test_support_smoke.py @@ -0,0 +1,23 @@ +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +import docket.ledger as ledger +from docket import support + +LEDGER = Path(__file__).parent.parent / ".docket" / "ledger.jsonl" + + +class RepositoryLedgerTests(unittest.TestCase): + @unittest.skipUnless(LEDGER.exists(), "no project ledger in this checkout") + def test_no_current_record_loses_its_grounds(self): + # This ledger records no rejection, revocation or reversal, so nothing + # is lost to one; cycles built by forward resolution are flagged, not lost. + projected = ledger.project(ledger.read(LEDGER), validated=True) + lost = {e["id"]: e["lost_grounds"] for e in projected if support.surfaced(e, "unsupported")} + self.assertEqual(lost, {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_support_surfaces.py b/tests/test_support_surfaces.py new file mode 100644 index 0000000..191462f --- /dev/null +++ b/tests/test_support_surfaces.py @@ -0,0 +1,140 @@ +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +import docket.ledger as ledger +from docket import where +from docket.context import build_context +from docket.context_delta import build_delta + + +def claim(ident, **kwargs): + kwargs.setdefault("state", "accepted") + return ledger.make_record( + "claim", f"Claim {ident} holds.", author="test", record_id=ident, **kwargs + ) + + +FLAGGED = [claim("c1"), claim("c2", supports=[["c1"]]), claim("c3", supersedes=["c1"])] +LOST = [ + claim("c1"), + claim("c2", supports=[["c1"]]), + claim("c3", supersedes=["c1"], supersede_reason="reverse"), +] + + +class WhereTests(unittest.TestCase): + def ids(self, entries, query): + q = where.parse(query) + return [e["id"] for e in ledger.project(entries) if q.matches(e)] + + def test_is_flagged(self): + self.assertEqual(self.ids(FLAGGED, "is:flagged"), ["c2"]) + + def test_is_unsupported(self): + self.assertEqual(self.ids(LOST, "is:unsupported"), ["c2"]) + + +class BriefingTests(unittest.TestCase): + def test_a_flagged_record_renders_review_owed_and_the_footer_counts_it(self): + text = build_context(ledger.project(FLAGGED), all_records=True) + self.assertIn('review_owed: [{"because": "revise", "ground": "c1", "head": "c3"}]', text) + self.assertIn("# owe review: 1; docket list --where is:flagged", text) + + def test_an_unsupported_record_renders_lost(self): + text = build_context(ledger.project(LOST), all_records=True) + self.assertIn('lost: [{"because": "reverse", "ground": "c1"}]', text) + + def test_an_unsupported_prerequisite_does_not_block_its_dependent(self): + entries = LOST + [ + ledger.make_record( + "decision", + "Ship it.", + author="test", + record_id="d4", + state="adopted", + choice="ship", + depends_on=["c2"], + ) + ] + text = build_context(ledger.project(entries), all_records=True) + self.assertIn("applicable: true", text) + self.assertNotIn("blocked:", text) + + +def decision(ident, depends_on, **kwargs): + return ledger.make_record( + "decision", + "Ship it.", + author="test", + record_id=ident, + state="adopted", + choice="ship", + depends_on=depends_on, + **kwargs, + ) + + +class BlockedLineTests(unittest.TestCase): + def blocked(self, entries): + text = build_context(ledger.project(entries), all_records=True) + return [line for line in text.splitlines() if line.startswith("blocked:")] + + def test_a_restated_prerequisite_is_followed_to_its_head(self): + entries = [ + claim("c1"), + claim("c2", state="unassessed"), + claim("c3", supersedes=["c1"], supersede_reason="restate"), + decision("d4", ["c1", "c2"]), + ] + lines = self.blocked(entries) + self.assertEqual(lines, ["blocked: c2 unassessed"]) + + def test_a_revised_prerequisite_blocks_at_its_rejected_head(self): + entries = [ + claim("c1"), + claim("c3", state="rejected", supersedes=["c1"], supersede_reason="revise"), + decision("d4", ["c1"]), + ] + self.assertEqual(self.blocked(entries), ["blocked: c3 rejected"]) + + def test_a_reversed_prerequisite_blocks_at_the_recorded_prerequisite(self): + entries = [ + claim("c1"), + claim("c3", supersedes=["c1"], supersede_reason="reverse"), + decision("d4", ["c1"]), + ] + self.assertEqual(self.blocked(entries), ["blocked: c1 reversed"]) + + +class DeltaTests(unittest.TestCase): + def test_an_already_unsupported_record_is_not_reported_again(self): + entries = LOST + [claim("c4")] + baseline = ledger.project(LOST) + text = build_delta(ledger.project(entries), since="c3", baseline=baseline, raw=entries) + self.assertIn("0 no longer available", text) + + def test_newly_flagged_records_are_reported(self): + history = ledger.project(FLAGGED) + baseline = ledger.project(FLAGGED[:2]) + text = build_delta(history, since="c2", baseline=baseline, raw=FLAGGED) + self.assertIn("1 newly owe review", text) + + def test_a_record_that_became_unsupported_is_no_longer_available(self): + history = ledger.project(LOST) + baseline = ledger.project(LOST[:2]) + text = build_delta(history, since="c2", baseline=baseline, raw=LOST) + self.assertIn("2 no longer available", text) + self.assertIn("### c2 | claim | accepted [changed]", text) + + def test_a_stale_digest_falls_back(self): + history = ledger.project(FLAGGED) + baseline = ledger.project(FLAGGED[:2]) + self.assertIsNone( + build_delta(history, since="c2@000000000000", baseline=baseline, raw=FLAGGED) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_where.py b/tests/test_where.py index d0f8603..918b80b 100644 --- a/tests/test_where.py +++ b/tests/test_where.py @@ -222,7 +222,9 @@ def test_retired_and_blocked_are_not_states(self): self.assertRefused("state:blocked", "use is:blocked") def test_an_unknown_is_value(self): - self.assertRefused("is:open", "is:open", "blocked, corrected, pinned, retired") + self.assertRefused( + "is:open", "is:open", "blocked, corrected, flagged, pinned, retired, unsupported" + ) def test_a_malformed_date(self): for query in ("after:2026-9-1", "before:2026-13-01", "after:20260901", "after:yesterday"):