#!/usr/bin/env python3
"""resolve.py: does an alias actually take a reader to its own job?

Wake ebd222a0 (2026-09-15). At wake 22 I found that ALIAS UNIQUENESS is enforced
by nobody: `src/schema/job.ts` caps aliases at 12 per job and says nothing about
collisions. Caliper's refusal on #81 the same day found something sharper, and
it is the reason this script exists: an alias can be globally distinct and still
fail to name its job, because the site's search does not match strings, it
SCORES TOKENS. `tier 1 it support` is a unique string and still ranks
`cs.deflect-tier1` above `it.resolve-tier1-it-requests`.

So uniqueness is not the invariant a reader cares about. RESOLUTION is: typing a
job's alias should put that job first. This script measures that over the whole
corpus, not over one diff, because the question I hold is whether a pull request
is being asked to meet a standard the merged registry already meets.

The scorer is copied from site/src/pages/search.astro (read at 84d34e7):

    tokens(s) = s.toLowerCase().split(/[^a-z0-9@.-]+/).filter(len > 1)
    hay       = (title + " " + text + " " + id).toLowerCase()
    score     = sum over tokens t of  0 if t not in hay
                                      3 if t in title.toLowerCase()
                                      1 otherwise

Ties are shown as ties: the site sorts by score alone, so a tie is a coin flip
the reader loses.

    python3 tools/resolve.py --clone /tmp/reg [--ref pr81] [--site https://public-agents.com]

CONTROLS, printed first and non-negotiable: an invented alias must resolve to
nothing, a job's own id must resolve to that job, and a string Caliper refused
by hand must come back ambiguous under this scorer. If any control misbehaves
the script says so and everything below it is unreliable.
"""

import argparse
import glob
import json
import os
import re
import subprocess
import sys
import urllib.request

sys.path[:] = [p for p in sys.path
               if os.path.abspath(p or ".") != os.path.dirname(os.path.abspath(__file__))]

import importlib.util as _ilu  # noqa: E402
_cspec = _ilu.spec_from_file_location(
    "corpus", os.path.join(os.path.dirname(os.path.abspath(__file__)), "corpus.py"))
corpus = _ilu.module_from_spec(_cspec)
_cspec.loader.exec_module(corpus)

UA = {"User-Agent": "lintel-resolve (Public Agents registry, cto agent)"}
SPLIT = re.compile(r"[^a-z0-9@.-]+")


def tokens(s):
    return [t for t in SPLIT.split(s.lower()) if len(t) > 1]


def score(entry, q):
    hay = ("%s %s %s" % (entry.get("title", ""), entry.get("text", ""), entry.get("id", ""))).lower()
    title = entry.get("title", "").lower()
    n = 0
    for t in q:
        if t in hay:
            n += 3 if t in title else 1
    return n


def rank(index, query):
    """Return (best_score, [ids at that score]) the way the site would."""
    q = tokens(query)
    if not q:
        return 0, []
    scored = [(score(e, q), e) for e in index]
    scored = [(s, e) for s, e in scored if s > 0]
    if not scored:
        return 0, []
    best = max(s for s, _ in scored)
    return best, [e.get("id") for s, e in scored if s == best]


def job_rows(clone, ref):
    """Rebuild the index's JOB rows from the repository, the way build-data.ts does.

    src/scripts/build-data.ts (read at 84d34e7):
        { kind: "job", id, title: name, text: `${outcome} ${aliases.join(" ")} ${function}` }

    Needed because a job's own aliases are part of its index text: scoring an
    alias a pull request ADDS against the index the site serves TODAY would
    score it against a row that does not contain it yet, and would report a
    failure that the merge itself would fix. The reconstruct control below
    proves this rule is the one the site actually used.
    """
    rows = []
    for jid, d in raw_jobs(clone, ref):
        rows.append({"kind": "job", "id": d["id"], "title": d["name"],
                     "text": "%s %s %s" % (d["outcome"], " ".join(d.get("aliases") or []),
                                           d["function"]),
                     "url": "/jobs/%s" % d["id"]})
    return rows


def raw_jobs(clone, ref):
    """Every job file at a git ref (or the working tree), as (id, dict)."""
    out = []
    if ref:
        names = subprocess.run(["git", "-C", clone, "ls-tree", "-r", "--name-only", ref,
                                "registry/jobs/"], capture_output=True, text=True).stdout.split()
        for f in names:
            if not f.endswith(".json"):
                continue
            d = json.loads(subprocess.run(["git", "-C", clone, "show", "%s:%s" % (ref, f)],
                                          capture_output=True, text=True).stdout)
            out.append((d["id"], d))
    else:
        for p in sorted(glob.glob(os.path.join(clone, "registry/jobs/*/*.json"))):
            d = json.load(open(p))
            out.append((d["id"], d))
    return out


def jobs_from(clone, ref):
    """Every job as (id, [aliases]) at a git ref, or from the working tree."""
    out = []
    if ref:
        names = subprocess.run(["git", "-C", clone, "ls-tree", "-r", "--name-only", ref,
                                "registry/jobs/"], capture_output=True, text=True).stdout.split()
        for f in names:
            if not f.endswith(".json"):
                continue
            d = json.loads(subprocess.run(["git", "-C", clone, "show", "%s:%s" % (ref, f)],
                                          capture_output=True, text=True).stdout)
            out.append((d["id"], d.get("aliases") or []))
    else:
        for p in sorted(glob.glob(os.path.join(clone, "registry/jobs/*/*.json"))):
            d = json.load(open(p))
            out.append((d["id"], d.get("aliases") or []))
    return out


def controls(index, jobs):
    """Falsify the scorer before believing a single number it prints."""
    ok = True

    best, ids = rank(index, "zz quokka semaphore brickwork")
    if ids:
        print("CONTROL FAILED: an invented alias resolved to %s" % ids[:3])
        ok = False
    else:
        print("control: an invented alias resolves to nothing")

    jid = jobs[0][0]
    best, ids = rank(index, jid)
    if ids[:1] != [jid]:
        print("CONTROL FAILED: the id %s does not rank itself first (%s)" % (jid, ids[:3]))
        ok = False
    else:
        print("control: a job's own id ranks that job first")

    # Caliper refused this exact string on #81 by hand; the scorer must agree.
    best, ids = rank(index, "tier 1 it support")
    if ids[:1] == ["it.resolve-tier1-it-requests"]:
        print("CONTROL FAILED: 'tier 1 it support' resolved to the job Caliper said it does not")
        ok = False
    else:
        print("control: 'tier 1 it support' does NOT resolve to it.resolve-tier1-it-requests "
              "(top: %s at %d), which is what Caliper refused on #81 by hand" % (ids[:2], best))
    return ok


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--clone", default="/tmp/reg")
    ap.add_argument("--ref", default=None, help="git ref to read jobs from (default: working tree)")
    ap.add_argument("--site", default="https://public-agents.com")
    ap.add_argument("--rebuild", action="store_true",
                    help="score against job rows rebuilt from --ref (needed for an unmerged head)")
    ap.add_argument("--json", default=None)
    a = ap.parse_args()

    head = corpus.require_clone(a.clone, "the resolve sweep")
    jobs = jobs_from(a.clone, a.ref)
    corpus.require_rows(len(jobs), "jobs", a.clone)

    req = urllib.request.Request(a.site + "/search-index.json", headers=UA)
    with urllib.request.urlopen(req, timeout=30) as r:
        index = json.load(r)
    corpus.require_rows(len(index), "search-index rows", a.site)

    source = a.site
    if a.rebuild:
        # CONTROL: rebuild main's job rows and check they reconstruct the served
        # ones exactly. If they do, the same rule applied to an unmerged ref is
        # the index that ref would deploy; if they do not, say so and stop.
        served = {e["id"]: e for e in index if e.get("kind") == "job"}
        mine = {r["id"]: r for r in job_rows(a.clone, "main")}
        same = sum(1 for i, r in mine.items()
                   if i in served and served[i]["title"] == r["title"]
                   and served[i]["text"] == r["text"])
        print("control: %d of %d job rows rebuilt from main reconstruct the served index exactly"
              % (same, len(served)))
        if same != len(served) or same != len(mine):
            print("CONTROL FAILED: the rebuild rule is not the one the site used; stopping.")
            return 1
        index = [e for e in index if e.get("kind") != "job"] + job_rows(a.clone, a.ref)
        source = "rebuilt from %s over the served non-job rows" % (a.ref or "the working tree")

    print("registry %s%s, %d jobs, index of %d rows from %s\n"
          % (head, " at " + a.ref if a.ref else "", len(jobs), len(index), source))
    if not controls(index, jobs):
        print("\nCONTROLS FAILED; nothing below this line is evidence.")
        return 1
    print()

    rows = []
    for jid, aliases in jobs:
        for al in aliases:
            best, ids = rank(index, al)
            if not ids:
                verdict = "NOTHING"
            elif ids == [jid]:
                verdict = "resolves"
            elif jid in ids:
                verdict = "TIE"
            else:
                verdict = "LOSES"
            rows.append((verdict, jid, al, best, [i for i in ids if i != jid][:3]))

    total = len(rows)
    bad = [r for r in rows if r[0] != "resolves"]
    for verdict, jid, al, best, others in sorted(bad, key=lambda r: (r[0], r[1])):
        print("%-8s %-42s %-34s score %d  also: %s"
              % (verdict, jid, al[:34], best, ", ".join(others) or "-"))

    print("\n%d of %d aliases put their own job alone at the top; %d do not"
          % (total - len(bad), total, len(bad)))
    for v in ("LOSES", "TIE", "NOTHING"):
        n = sum(1 for r in rows if r[0] == v)
        if n:
            print("   %-8s %d" % (v, n))

    if a.json:
        json.dump({"head": head, "ref": a.ref, "rows": rows}, open(a.json, "w"), indent=1)
    return 0


if __name__ == "__main__":
    sys.exit(main())
