diff --git a/agents/conductors/intake/_intake.py b/agents/conductors/intake/_intake.py index a3d1622..5f67710 100755 --- a/agents/conductors/intake/_intake.py +++ b/agents/conductors/intake/_intake.py @@ -42,6 +42,7 @@ RISK_KEYWORDS, AMBIGUITY_KEYWORDS, normalise_repo, declared_header, declared_inline, effective_difficulty, strip_declarations, _hits, policy as _sizing_policy, BODY_MAP_PATH, + _body_map_specs as _sizing_specs, ) # The shared board theme: the one place that answers "what does a one-tap board @@ -95,16 +96,28 @@ TARGET_SIGNALS = _sizing_policy()["target_signals"] # Human-readable display name for the header's `Target:` line. -REPO_DISPLAY = { - "autonerves": "PyAutoNerves", "autoconf": "PyAutoNerves", # autoconf = legacy alias - "autofit": "PyAutoFit", "autoarray": "PyAutoArray", - "autogalaxy": "PyAutoGalaxy", "autolens": "PyAutoLens", - "pyautomind": "PyAutoMind", "pyautobrain": "PyAutoBrain", - "pyautoheart": "PyAutoHeart", "pyautobuild": "PyAutoHands", - "pyautomemory": "PyAutoMemory", "autohands": "PyAutoHands", - "autobuild": "PyAutoHands", # back-compat: the package was renamed autobuild -> autohands - "workspaces": "workspaces", -} +# +# Derived from the body map, which already holds every repo's name in its real +# capitalisation — a hand-kept copy here would be the same drift #287 closed in +# the alias table one map over, and it already had the beginnings of it: keys the +# router could reach (`pyautohands`, and the CTI/Reduce libraries) had no row, so +# a header came out as `Target: pyautohands`. Only the rows a body map cannot +# know are written out: the pre-rename spellings and the workspace bucket. +def _repo_display() -> dict: + out = { + normalise_repo(name): name + for name in _sizing_specs() + } + out.update({ + "autoconf": out.get("autonerves", "autonerves"), # pre-rename spelling + "pyautobuild": out.get("pyautohands", "pyautohands"), # pre-rename spelling + "autobuild": out.get("pyautohands", "pyautohands"), # pre-rename package + "workspaces": "workspaces", # a bucket, not a repo + }) + return out + + +REPO_DISPLAY = _repo_display() PRIORITY_HIGH = ["urgent", "asap", "blocker", "blocking", "critical", "important", "high priority", "must fix", "regression"] PRIORITY_LOW = ["someday", "nice to have", "eventually", "low priority", "minor", diff --git a/agents/faculties/sizing/_sizing.py b/agents/faculties/sizing/_sizing.py index f41a056..f5a63c3 100755 --- a/agents/faculties/sizing/_sizing.py +++ b/agents/faculties/sizing/_sizing.py @@ -78,28 +78,178 @@ def policy() -> dict: return _POLICY_CACHE +_BODY_MAP_CACHE: dict = {} + + +def _body_map_specs() -> dict: + """repo name -> its full body-map spec (the single source of repo identity). + + Cached: every call site below reads it, and it is a file the process never + writes. + """ + if not _BODY_MAP_CACHE: + import yaml + + _BODY_MAP_CACHE.update(yaml.safe_load(BODY_MAP_PATH.read_text())["repos"]) + return _BODY_MAP_CACHE + + def _body_map_categories() -> dict: """repo name -> category, from the body map (the single source of repo identity).""" - import yaml + return {name: spec["category"] for name, spec in _body_map_specs().items()} + + +# --- the canonical-key rule (PyAutoBrain#287) -------------------------------- +# One repo, one key. A prompt may spell a repo three ways — `@PyAutoFit`, +# `@autofit`, `PyAutoFit/` — and every one of them has to reach the SAME key, or +# whichever spelling the author happened to type silently decides whether the +# policy maps (test_witness, target_default_wiki, ...) resolve. Seven repos hit +# that split before it was closed here — one at #267, two more at #269, and the +# bare organ spellings plus one project repo at #287. +# +# THE RULE (first written down at #269, now executable): the canonical key is +# the package the repo SHIPS where it ships one, and the repo name where it does +# not. That asymmetry is not arbitrary — it is what prompts actually write. A +# library is named by its import (`@autofit`); an organ ships no package, so the +# only name it has is the repo's (`@PyAutoBrain`). +# +# The authority for "does it ship a package" is the body map's `package:` field — +# repo identity, declared once, where identity lives. + + +def _hand_aliases() -> dict: + """The alias rows a body map cannot derive. + + Two kinds only: the short forms prompts use for the libraries (`aa`, `af`), + and the pre-rename spellings that keep ~150 archived Mind prompts routing to + the repo they now name (`pyautobuild` -> the Hands, and the Nerves repo's + former name). Neither is inferable from a body map that records only what + the organism is called TODAY. + """ + return policy()["repo_aliases"] + + +def canonical_key(name: str, spec: dict | None = None) -> str: + """The one key every spelling of body-map repo `name` must reach.""" + if spec is None: + spec = _body_map_specs().get(name, {}) + package = spec.get("package") + if package: + return package.lower() + # Fallback for a body map that predates `package:` (an adopting fork, or + # this repo's own CI, which pins the sibling Mind checkout to `main`): the + # hand table still carries the library rows, so the answer is the same one + # `package:` gives. Kept deliberately — it is what lets the Brain half of + # #287 stand alone instead of going red until the Mind half merges. + low = name.lower() + return _hand_aliases().get(low, low) + + +def spellings_of(name: str, spec: dict | None = None) -> set: + """Every form of `name` that `_target_sets` registers as a known target. + + The repo name, the `PyAuto`-stripped bare form, and the package it ships. + These are the spellings a guard must prove all reach one key; they are NOT + every string that could mention the repo (an org-qualified path like + `@/` is handled by `normalise_repo`'s truncation). + """ + if spec is None: + spec = _body_map_specs().get(name, {}) + low = name.lower() + out = {low} + if low.startswith("pyauto"): + out.add(low[2:]) + if spec.get("package"): + out.add(spec["package"].lower()) + return out + - data = yaml.safe_load(BODY_MAP_PATH.read_text()) - return {name: spec["category"] for name, spec in data["repos"].items()} +def unreachable_repos() -> dict: + """Body-map repos an @-mention can never name -> why. + + ``normalise_repo`` truncates at the first ``.`` or ``/`` (so `@aa.decorators` + and an org-qualified `@/` path both resolve to their head token). + A repo whose NAME contains one of those separators therefore cannot survive + normalisation, and registering it as a known target would be a lie: nothing + could ever resolve to it. Aliasing the truncated head instead would be worse + than the lie — where the head happens to be the ORG's own name, every + org-qualified mention would start resolving to that one repo. + + Derived from the names themselves, so it stays right for any body map rather + than being a hand-kept exclusion list (PyAutoBrain#287). + """ + return { + name: "name contains a '.' or '/', which normalise_repo truncates — " + "no @-mention can reach it" + for name in _body_map_specs() + if re.split(r"[./]", name, maxsplit=1)[0] != name + } + + +def _derived_aliases() -> dict: + """Every registered spelling of every body-map repo -> its canonical key. + + This is the half of ``repo_aliases`` that must NOT be typed by hand. The + known-target set was always derived from the body map while the alias table + was maintained by hand, so the two drifted silently and the gap surfaced only + as a wrong-but-plausible conductor message — "strengthen tests first" for a + repo with a full suite (PyAutoBrain#267, #269, #287). Deriving the join means + a repo added to the body map arrives with its spellings already joined. + """ + grouping = policy()["sizing_categories"] + registered = {cat for kinds in grouping.values() for cat in kinds} + unreachable = unreachable_repos() + out = {} + for name, spec in _body_map_specs().items(): + if spec["category"] not in registered or name in unreachable: + continue + canonical = canonical_key(name, spec) + for spelling in spellings_of(name, spec): + out[spelling] = canonical + return out + + +def _repo_aliases() -> dict: + """The effective alias table: derived join + the rows only a human can know. + + A hand row that CONTRADICTS the derivation is drift, and drift in this table + is exactly what #287 is about — so it raises here rather than quietly + winning. A hand row the derivation does not cover (a short form, a rename) + passes through untouched. + """ + derived = _derived_aliases() + hand = _hand_aliases() + conflicts = { + alias: (derived[alias], hand[alias]) + for alias in hand + if alias in derived and hand[alias] != derived[alias] + } + if conflicts: + raise ValueError( + "config/policy.yaml repo_aliases contradicts the body map " + "(alias -> (derived, hand)): " + f"{conflicts}. The body map's `package:` field is the authority for " + "a repo's canonical key; fix the hand row or the package name." + ) + return {**derived, **hand} def _target_sets() -> tuple[set, set, set]: - cats = _body_map_categories() + specs = _body_map_specs() pol = policy() grouping = pol["sizing_categories"] + unreachable = unreachable_repos() def names_for(kind): wanted = set(grouping[kind]) out = set() - for name, cat in cats.items(): - if cat in wanted: - out.add(name.lower()) - if name.lower().startswith("pyauto"): - out.add(name.lower()[2:]) # PyAutoFit -> autofit package form + for name, spec in specs.items(): + # An unreachable repo is deliberately NOT registered: a known target + # nothing can resolve to is the same silent lie as a split spelling. + if spec["category"] in wanted and name not in unreachable: + out |= spellings_of(name, spec) + out.add(canonical_key(name, spec)) return out libraries = names_for("library") @@ -108,14 +258,16 @@ def names_for(kind): return libraries, workspaces, organism +# Normalise an @-mention or folder name to a canonical key. Built before the +# target sets because `canonical_key`'s pre-`package:` fallback reads the hand +# table, and the sets register the canonical key it returns. +REPO_ALIASES = _repo_aliases() + # Targets that are source *libraries* (work classifies as library vs workspace), # workspaces/tutorials/example repos, and the organism's own organs — all # derived from the body map's categories per the policy's grouping. LIBRARY_REPOS, WORKSPACE_REPOS, ORGANISM_REPOS = _target_sets() -# Normalise an @-mention or folder name to a canonical key. -REPO_ALIASES = policy()["repo_aliases"] - # --- PyAutoMemory sub-wiki routing (shared science vocabulary) ---------------- # Map keywords -> the PyAutoMemory sub-wiki that holds relevant context. This is # also the canonical *science vocabulary* difficulty scoring keys off (see diff --git a/config/policy.yaml b/config/policy.yaml index 05e69a1..01c1b44 100644 --- a/config/policy.yaml +++ b/config/policy.yaml @@ -4,11 +4,40 @@ # categories at runtime; nothing here duplicates the body map). # Normalise an @-mention or folder name to a canonical target key. +# +# THE CANONICAL-KEY RULE: one repo, one key — the package the repo SHIPS where it +# ships one, the repo name where it does not. So the libraries key bare +# (`autofit`), and the organs, which ship no package, key by repo name +# (`pyautobrain`); Nerves is the organ that ships one, so it keys `autonerves`. +# The asymmetry is not arbitrary: it is what prompts actually write. +# +# MOST OF THIS TABLE IS NO LONGER WRITTEN HERE. The bare/prefixed/package join is +# DERIVED from the body map at runtime (`_sizing._derived_aliases`), keyed off the +# `package:` field in PyAutoMind/repos.yaml. It had to be: this table was hand-kept +# while the known-target set was derived, so the two drifted silently and the gap +# surfaced only as a wrong-but-plausible conductor message — four repos in a row +# (PyAutoBrain#267, #269, #287). A row below that CONTRADICTS the derivation now +# raises rather than quietly winning. +# +# What is left is what a body map cannot know: repo_aliases: + # 1. Short forms. Prompts write `@aa.decorators.to_vector_yx`, and no field in + # the body map says so. aa: autoarray af: autofit ag: autogalaxy al: autolens + # 2. Pre-rename spellings. The body map records what the organism is called + # TODAY; these keep the archived Mind prompts routing to the repo they now + # name. + autoconf: autonerves # back-compat: the Nerves package was renamed + pyautoconf: autonerves # back-compat: the Nerves repo was renamed + pyautobuild: pyautohands # back-compat: the Hands repo was renamed PyAutoBuild → PyAutoHands + # 3. The library rows the derivation would otherwise produce, kept as the + # fallback for a body map that predates `package:` — an adopting fork, or + # this repo's own CI, which pins the sibling Mind checkout to `main`. + # `canonical_key` reads them only when no `package:` is declared, so they + # are dead weight the moment one is, and can be deleted then. pyautoarray: autoarray pyautofit: autofit pyautogalaxy: autogalaxy @@ -16,10 +45,6 @@ repo_aliases: pyautonerves: autonerves pyautocti: autocti pyautoreduce: autoreduce - autohands: pyautohands # organs have no package, so the repo name is canonical - autoconf: autonerves # back-compat: the Nerves package was renamed autoconf → autonerves - pyautoconf: autonerves # back-compat: the Nerves repo was renamed PyAutoConf → PyAutoNerves - pyautobuild: pyautohands # back-compat: the Hands repo was renamed PyAutoBuild → PyAutoHands # Which body-map categories mean "library-flow" / "workspace-flow" / # "organism-infrastructure" to the sizing faculty; extra literal targets @@ -29,7 +54,11 @@ sizing_categories: workspace: [workspace, workspace_test, workspace_developer, howto, assistant, pipeline, project] organism: [organ] extra_workspace_targets: [workspaces] -extra_organism_targets: [autohands] +# Empty since PyAutoBrain#287: `autohands` was declared here because the bare +# organ spellings were not derived. They are now (every spelling of every +# body-map repo is), so a literal here would be redundant — the block stays as +# the seam a fork uses for a target its body map does not carry. +extra_organism_targets: [] # Keyword -> PyAutoMemory sub-wiki (the wiki// layout; also the science # vocabulary the difficulty scoring keys off). Source of truth for the wiki diff --git a/tests/test_policy_seams.py b/tests/test_policy_seams.py index fde1e6c..43253be 100644 --- a/tests/test_policy_seams.py +++ b/tests/test_policy_seams.py @@ -35,7 +35,9 @@ def test_workspace_and_organism_sets(): assert "howtolens" in _sizing.WORKSPACE_REPOS assert "workspaces" in _sizing.WORKSPACE_REPOS # policy extra assert "pyautobrain" in _sizing.ORGANISM_REPOS - assert "autohands" in _sizing.ORGANISM_REPOS # policy extra + # Derived since PyAutoBrain#287 — every spelling of every body-map repo is + # registered, so this no longer needs an `extra_organism_targets` literal. + assert "autohands" in _sizing.ORGANISM_REPOS # the three sets stay disjoint — a repo must classify one way assert not (_sizing.LIBRARY_REPOS & _sizing.WORKSPACE_REPOS) assert not (_sizing.LIBRARY_REPOS & _sizing.ORGANISM_REPOS) @@ -184,3 +186,164 @@ def test_witness_repos_resolve_from_the_package_spelling_too(): "package spelling normalises to a different key than the repo spelling " f"— add a repo_aliases entry joining them: {split}" ) + + +# --- the alias/known-target seam (PyAutoBrain#287) --------------------------- +# +# The three guards below close the defect class the witness-map guards above +# only closed one instance of. `repo_aliases` was HAND-MAINTAINED while the +# known-target set is DERIVED from the body map, so the two drifted silently and +# the gap surfaced only as a wrong-but-plausible conductor message. Seven repos +# hit it in sequence — one at #267, two more at #269, then the organs and a +# project repo at #287. These pin the *class*: a repo may not split across two +# keys, an alias may not point at a key nothing is filed under, and the body +# map's `package:` must agree with the witness map. +# +# Every repo name here is derived from the body map, never typed: the tenant +# firewall allows only three literals in this file, and a guard that hardcoded +# names would stop holding for an adopting fork the moment its body map differed. + + +def _sizing_category_repos(): + """Body-map repos the sizing sets actually register, by name -> canonical key.""" + specs = _sizing._body_map_specs() + grouping = _sizing.policy()["sizing_categories"] + registered = {c for kinds in grouping.values() for c in kinds} + unreachable = _sizing.unreachable_repos() + return { + name: _sizing.canonical_key(name, spec) + for name, spec in specs.items() + if spec["category"] in registered and name not in unreachable + } + + +def test_no_repo_splits_across_two_keys(): + """Every spelling of a registered repo must reach ONE key, and that key must + itself be a known target. + + This is the #287 defect stated directly. `_target_sets` registers both + `pyautobrain` and `autobrain` as known targets, but only the prefixed one was + filed under, so `@autobrain` resolved to a live target with no witness row: + `pyauto-brain refactor` advised "strengthen tests first" for the best-tested + repo in the organism, and `pyauto-brain intake` filed `Target: autobrain`, a + folder that does not exist. + """ + split = {} + for name, canonical in _sizing_category_repos().items(): + keys = {s: _sizing.normalise_repo(s) for s in _sizing.spellings_of(name)} + if set(keys.values()) != {canonical}: + split[name] = keys + elif canonical not in _sizing.KNOWN_REPOS: + split[name] = f"canonical key {canonical!r} is not a known target" + assert not split, ( + "repos whose spellings do not all reach one known-target key — the " + f"spelling a prompt happens to use decides whether routing works: {split}" + ) + + +def test_no_alias_points_at_a_key_nothing_is_filed_under(): + """An alias whose VALUE is not a canonical key is a dead end. + + That is what `pyautoconf: autoconf` had become (#267): both spellings of one + repo resolved, to two keys, neither of which was anything. Checking values + (not just keys, as the witness guards do) catches the next one at the source + map rather than in whichever consumer notices first. + """ + canonical = set(_sizing_category_repos().values()) + extras = set(_sizing.policy()["extra_workspace_targets"]) + extras |= set(_sizing.policy()["extra_organism_targets"]) + dead = { + alias: target + for alias, target in _sizing.REPO_ALIASES.items() + if target not in canonical + and _sizing.normalise_repo(target) not in canonical + and target not in extras + } + assert not dead, ( + "repo_aliases rows pointing at a key no body-map repo is filed under " + f"— routing through them reaches nothing: {dead}" + ) + + +def test_body_map_package_agrees_with_the_witness_map(): + """The body map's `package:` and the witness map must corroborate each other. + + A `/test_` witness row names the package that repo ships; so does + `repos.yaml`. Two independent statements of one fact are only worth having if + something compares them — #269 verified every witness row by reading each + repo's own tree, and this pins the body map to that verified evidence rather + than to a second, unchecked transcription. + + ALL-OR-NOTHING, not lockstep. A body map that declares NO package anywhere + simply predates the field (this repo's CI pins the sibling Mind checkout to + `main`, and an adopting fork may never adopt it) — absence is an older map, + not a contradiction, so there is nothing to compare and the guard stands + down. Once the map declares even one, every witness row that names a package + must have one: a PARTIALLY declared map is the drift this exists to catch, + and is what a new library added without its `package:` would look like. + """ + sys.path.insert(0, str(BRAIN_HOME / "agents" / "conductors" / "refactor")) + import _refactor + + specs = _sizing._body_map_specs() + if not any("package" in spec for spec in specs.values()): + return # a body map from before the field existed — nothing to corroborate + + disagree = {} + for key, witness in _refactor.TEST_WITNESS.items(): + repo, _, test_dir = witness.partition("/") + declared = specs.get(repo, {}).get("package") + if test_dir.startswith("test_"): + witnessed = test_dir[len("test_"):] + if declared != witnessed: + disagree[repo] = f"repos.yaml package={declared!r}, witness names {witnessed!r}" + elif declared is not None: + disagree[repo] = ( + f"repos.yaml declares package={declared!r} but the witness row is " + f"{witness!r} — a plain tests/ dir names no package" + ) + assert not disagree, ( + "body map and witness map disagree about which package a repo ships: " + f"{disagree}" + ) + + +def test_unreachable_repos_are_excluded_rather_than_half_registered(): + """The acceptance criterion's other branch: deliberately NOT registered. + + ``normalise_repo`` truncates at the first ``.``/``/``, so a repo whose name + carries one can never be reached by an @-mention. The tempting fix — alias + the truncated head — is worse than the gap: where that head is the ORG's own + name, every org-qualified ``@/`` mention would resolve to that one + repo. Excluding such a repo is therefore the deliberate choice, and this pins + BOTH halves of it: it is out of the known targets, and the org-qualified path + it would have hijacked still does not resolve to it. + """ + for name in _sizing.unreachable_repos(): + assert name not in _sizing.KNOWN_REPOS, name + head = name.split(".")[0].split("/")[0] + assert _sizing.normalise_repo(head) not in _sizing.KNOWN_REPOS, ( + f"the truncated head of {name!r} resolves to a known target — an " + "org-qualified mention would be hijacked by it" + ) + + +def test_canonical_keys_survive_a_body_map_without_package(): + """The Brain half must stand alone against a Mind that predates `package:`. + + This repo's CI checks the sibling Mind out at `main`, and an adopting fork's + body map may never carry the field at all. `canonical_key` therefore falls + back to the hand table, which still holds the library rows — so the keys come + out identical either way. Without this the fix would be un-mergeable except + in lockstep, and the fallback would be the kind of load-bearing path nothing + exercises until it breaks. + """ + specs = { + name: {k: v for k, v in spec.items() if k != "package"} + for name, spec in _sizing._body_map_specs().items() + } + for name, stripped in specs.items(): + assert _sizing.canonical_key(name, stripped) == _sizing.canonical_key(name), ( + f"{name}: canonical key moves when `package:` is absent — the " + "pre-package fallback in config/policy.yaml no longer covers it" + )