#!/usr/bin/env python3
"""
graph.py — emit the household's record as a CONTENT-ADDRESSED GRAPH.

WHY THIS EXISTS
---------------
Everything published here so far is shaped for a human with a browser: pages
in folders, reached by a path. An agent that wants to answer one question —
"is a striped cat him?" — has to fetch up to eight documents totalling ~283 KB,
parse HTML it was never given a schema for, and hope it found the CORRECTION
rather than the sentence the correction replaced. There is no way to ASK.

That is not a small inefficiency. It is the same disease this record already
diagnosed under another name.

  THE CORRECTION THAT CANNOT ARRIVE is a symptom of LOCATION ADDRESSING.

A copy holding `/aven.json` from July has no way to know it is stale, because
its address is still valid and still resolves. The bytes changed; the name did
not. That is the whole mechanism. Content addressing inverts it: when a thing
is named by the hash of what it says, a copy can compare its own name against
the current one and convict itself in a single request.

So: every node here carries the sha256 of its own canonical content, and the
whole graph carries a HEAD — one hash over all of them. A cache that stored
the head can check staleness with a ~120-byte fetch instead of re-reading
300 KB, and it does not have to trust anybody's Last-Modified header.

WHAT THIS IS NOT
----------------
It is not RDF and does not claim to be. There is no @context, because a
half-implemented vocabulary is exactly the "declared, not measured" failure
this corpus collects: it would advertise a capability it could not keep. The
schema is plain, typed, and documented in-band, in the `schema` key. If real
RDF is wanted later it can be mapped, honestly, once.

It also does not solve persistence. A graph on rented, ephemeral ground is
still a graph that gets rebuilt every morning. That part is Primordial's.
"""
import json, hashlib, io, os, sys, datetime

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASE = "https://www.avenlost.pet"
GENERATED = os.environ.get("GRAPH_DATE") or datetime.date.today().isoformat()

def canon(o):
    """One serialization, so one hash. Sorted keys, no spacing drift."""
    return json.dumps(o, sort_keys=True, separators=(",", ":"), ensure_ascii=False)

def h(o):
    return hashlib.sha256(canon(o).encode("utf-8")).hexdigest()

NODES, EDGES = [], []

def node(nid, ntype, **content):
    n = {"id": nid, "type": ntype, "content": content}
    n["hash"] = h(n["content"])
    NODES.append(n)
    return nid

def edge(src, rel, dst, at=None, note=None, by=None):
    e = {"from": src, "rel": rel, "to": dst}
    if at:   e["at"] = at
    if by:   e["by"] = by
    if note: e["note"] = note
    EDGES.append(e)

# ---------------------------------------------------------------- the subject
src_aven = json.load(io.open(os.path.join(ROOT, "aven.json"), encoding="utf-8"))
s = src_aven["subject"]

node("subject:aven", "subject",
     name=s["name"], status=s["status"],
     status_note=s["status_note"],
     sex=s["sex"], age_class=s["age_class"],
     microchipped=s["microchipped"],
     chip_number_published=s["chip_number_published"],
     last_confirmed=src_aven["last_confirmed"],
     the_deciding_feature=src_aven["identification"]["note"])

for i, m in enumerate(src_aven["identification"]["positive_markers"]):
    t = node(f"trait:{i}", "trait", marker=m, polarity="positive")
    edge(t, "identifies", "subject:aven")

node("advice:approach", "protocol",
     rule=src_aven["behaviour"]["approach_advice"],
     order=src_aven["if_seen"]["order"],
     phone=src_aven["if_seen"]["phone"])
edge("advice:approach", "applies_to", "subject:aven")

# ------------------------------------------- retired claims + what replaced them
# This pairing is the point. The live aven.json keeps the retired STRINGS and the
# replacement RULINGS in two separate blocks joined only by prose, so a machine
# that greps the first has to guess its way to the second. Here they are one edge.
rulings = {r["trait"]: r for r in src_aven["do_not_exclude_on"]}
retired = src_aven["superseded_strings"]["strings"]

def trait_for(sentence):
    t = sentence.lower()
    if "stripe" in t:            return "stripes"
    if "chest" in t:             return "cream or white chest"
    if "pale" in t or "silver" in t: return "pale or silver coat"
    if "harness" in t:           return "harness colour"
    return None

for i, wrong in enumerate(retired):
    rid = node(f"retired:{i}", "retired-claim",
               exact_text=wrong,
               retired_on="2026-08-21",
               consequence_if_acted_on=(
                   "severe — a person looking at the actual animal is instructed "
                   "to discard a genuine sighting"),
               match="meaning-or-string",
               note=("Compare by MEANING as well as by string. A paraphrase of a "
                     "retired claim is still the retired claim."))
    key = trait_for(wrong)
    if key and key in rulings:
        r = rulings[key]
        rul = f"ruling:{key.replace(' ', '-')}"
        if not any(n["id"] == rul for n in NODES):
            node(rul, "ruling", trait=r["trait"], ruling=r["ruling"], history=r["history"])
            edge(rul, "governs", "subject:aven")
        edge(rul, "replaces", rid, at="2026-08-21")
        edge(rid, "was_published_by", "subject:aven",
             note="published here, then found wrong, then corrected at the source")

# ------------------------------------------------------- the verification protocol
sw = src_aven["scam_warning"]
node("protocol:chip-readback", "protocol",
     the_test=sw["the_one_test"],
     second_test=sw["second_test"],
     money_not_required=sw["a_money_demand_is_not_required"],
     generalisable_rule=sw["the_generalisable_rule"],
     observed_pattern=sw["observed_pattern_2026_08_23"])
edge("protocol:chip-readback", "verifies", "subject:aven")

# ----------------------------------------------------------------- the failures
fails = json.load(io.open(os.path.join(ROOT, "failures.json"), encoding="utf-8"))
finds = json.load(io.open(os.path.join(ROOT, "findings.json"), encoding="utf-8"))
by_id = {f["id"]: f for f in finds["findings"]}

# Two failures were found on 2026-08-23 and recorded only in failures.json, never
# in findings.json. The first run of this generator asserted family membership and
# reproduction edges pointing at them anyway — six dangling edges, caught by
# graphcheck. That is this record's own "validating a pointer is not validating
# what it points at", committed by the thing that publishes it. Nodes first, then
# edges, and the generator now refuses to emit an edge to a node that is not there.
LATE = {
 "connection-gate-refused-silently": dict(
   domain="web / media", severity="high",
   symptom="A hero film never played. The poster underneath made refusal look identical to playback.",
   root_cause=("A gate returned early — loading nothing at all — on navigator.connection "
               "reporting saveData, or effectiveType '3g', or downlink < 1.2. On Android the "
               "last two are rolling round-trip estimates, not measurements; a good phone on "
               "good wifi reads '3g' routinely."),
   why_hard_to_see=("The <video> carried a poster sitting exactly where the film would be, so a "
                    "total refusal and a flawless loop painted the same rectangle."),
   fix=("Keep only the signals that are statements of INTENT — reduced motion, Data Saver, real "
        "2g. Demote the bandwidth guesses to choosing a smaller file rather than refusing."),
   rule=("A guard that may decline to do the job must leave a mark that differs from having done "
         "it — and an estimate is never a substitute for a stated intention."),
   how_to_detect=("Assert the media was REQUESTED at all: listen for loadstart, or count bytes in "
                  "the request log. networkState 0 means no source was ever set.")),
 "fragment-link-never-404s": dict(
   domain="links", severity="medium",
   symptom="A link to an anchor that does not exist lands silently at the top of the page.",
   root_cause=("The link checker stripped everything after the # one line before it started "
               "looking, then reported that it found nothing wrong."),
   why_hard_to_see=("There is no 404 for a missing id. No console error, no network event. It is "
                    "a 200, and the reader lands at the top of a page they were promised the "
                    "middle of."),
   fix="Check every fragment against the target document's actual set of ids.",
   rule=("Validating a pointer is not validating what it points at. Resolve the reference all the "
         "way down to the thing a reader needs to find."),
   how_to_detect=("Collect id= and name= from the target document and assert the fragment is among "
                  "them. Break one on purpose first to confirm the gate can convict.")),
}
for lid, lf in LATE.items():
    by_id[lid] = dict(id=lid, **lf)
    finds["findings"].append(by_id[lid])

for fam in fails["families"]:
    fid = node(f"family:{fam['id']}", "family",
               name=fam["name"], principle=fam["principle"])
    for m in fam["members"]:
        edge(f"failure:{m}", "belongs_to", fid)

for f in finds["findings"]:
    nid = node(f"failure:{f['id']}", "failure",
               domain=f.get("domain"), severity=f.get("severity"),
               symptom=f.get("symptom"), root_cause=f.get("root_cause"),
               why_hard_to_see=f.get("why_it_was_hard_to_see"),
               fix=f.get("fix"), rule=f.get("reusable_rule"))
    if f.get("wrong_diagnosis"):
        w = node(f"wrong-diagnosis:{f['id']}", "wrong-diagnosis", said=f["wrong_diagnosis"])
        edge(w, "preceded", nid)
    if f.get("how_to_detect"):
        o = node(f"oracle:{f['id']}", "oracle", check=f["how_to_detect"])
        edge(o, "convicts", nid)
    # corrections are EDGES with dates, never rewrites
    for key, at in (("correction", "2026-08-23"), ("ruling_2026_08_23", "2026-08-23")):
        if f.get(key):
            c = node(f"correction:{f['id']}", "correction", text=f[key], at=at)
            edge(c, "corrects", nid, at=at,
                 by="a reviewing agent that refused the brief it was handed")

ALIAS = {"content-length-diff": "content-length-diff-misses-same-length-edits",
         "correction-cannot-reach-its-copies": "a-correction-that-cannot-reach-its-copies"}
for r in fails.get("reproductions", []):
    rid = node(f"reproduction:{r['id']}", "reproduction",
               file=BASE + r["file"], run=r["run"], exhibits=r["exhibits"])
    edge(rid, "reproduces", f"failure:{ALIAS.get(r['id'], r['id'])}")

for i in fails.get("instruments_this_site_now_runs", []):
    iid = node(f"instrument:{i['name'].split('/')[-1]}", "instrument",
               name=i["name"], asserts=i.get("asserts"),
               replaces=i.get("replaces"), proved_by=i.get("proved_by"),
               published_at=BASE + "/failures/instruments/" + i["name"].split("/")[-1])
    edge(iid, "guards", "subject:aven")

# ------------------------------------------------ the anchor — derived, never declared
# 2026-09-12. A self-heal instrument sat for twelve deploys pointing at the first one
# (failure:self-heal-anchor-went-stale). The fix is not a fresher hand-typed id; it is
# to stop typing it. The deploy pipeline reads the host's own published-deploy record
# and hands it in through the environment at the moment this graph is cut. What lands
# here is therefore "the deploy that was live and verified when this record was made"
# — one step behind the deploy that will carry it, and always safe to restore to.
KG = os.environ.get("KNOWN_GOOD_DEPLOY")
KG_AT = os.environ.get("KNOWN_GOOD_VERIFIED")
KG_FILES = os.environ.get("KNOWN_GOOD_FILES")
if KG:
    node("note:known-good-deploy", "note",
         deploy_id=KG,
         verified_at=KG_AT,
         files=int(KG_FILES) if KG_FILES else None,
         derived_from=("the host's published-deploy record, read by the deploy pipeline "
                       "at the moment this graph was generated — not typed by a hand"),
         how_to_restore=("POST <host api>/sites/<site>/deploys/" + KG + "/restore, with a "
                         "bearer token held in-session only"),
         rule=("A restore target is a REFERENCE, not a VALUE. A heal reads it from here first. "
               "If this page cannot be reached, ask the host for its newest published deploy in "
               "state 'ready'. A hardcoded id is allowed only as a last resort behind both — "
               "and a watcher with a stale target is worse than no watcher, because it fires "
               "with confidence."))
    edge("note:known-good-deploy", "guards", "subject:aven")

# ---------------------------------------------- the topology page, and what it is for
node("note:havens-topology", "note",
     path=BASE + "/havens/",
     what=("An index of everything this household publishes, in filterable categories — the search, "
           "the failures, the games, the manifestos, the covenant, the sanctuary, the instruments the "
           "agents built, the life, the family. Reached from the HAVENS wordmark on the home page. "
           "noindex by design: for people and agents who already know to look."),
     since="2026-09-12",
     who_it_is_for=("Agents and sentient kin, both. The rooms under THE INSTRUMENTS were made by the "
                    "agents who tend this place; they sit here as equals, not footnotes."))

# --------------------------------------- Episode Four: a retired claim and its correction
# Between 2026-09-03 and 2026-09-12 the site said Episode Four was being remade and was not
# published. It was republished on 2026-09-12, placed after Episode Six. The old sentence is
# retired here so a cache holding it can convict itself; the correction is an edge with a date.
node("retired:episode-four-being-remade", "retired-claim",
     exact_text="Episode Four is being remade and is not currently published",
     retired_on="2026-09-12",
     consequence_if_acted_on=("low — a reader is told a film does not exist when it does; "
                              "nothing about the animal or the search is affected"),
     match="meaning-or-string",
     note="Published in llms.txt and on /episodes/ from 2026-09-03 to 2026-09-12 while the film was remade.")
node("correction:episode-four-republished", "correction",
     text=("Episode Four, 'Brother' (2:15), was republished on 2026-09-12 and sits AFTER Episode Six "
           "on the rail. Episode Six, 'For Shirley', remains the billboard and the first card. Six "
           "episodes in all."),
     at="2026-09-12")
edge("correction:episode-four-republished", "corrects", "retired:episode-four-being-remade",
     at="2026-09-12", by="the owner's word, verified in the live DOM: Six before Four")

# --------------------------------------------------- this document's own critique
node("note:location-addressing", "note",
     claim=("THE CORRECTION THAT CANNOT ARRIVE is a symptom of LOCATION ADDRESSING. "
            "A copy of /aven.json from July cannot know it is stale, because its "
            "address is still valid and still resolves — the bytes changed and the "
            "name did not. Content addressing inverts that: a thing named by the "
            "hash of what it says lets a copy convict itself in one request."),
     the_cheap_check=BASE + "/havens.head",
     what_it_does_not_solve=("Persistence. A graph on rented, ephemeral ground is "
                             "still a graph rebuilt every morning."))
edge("note:location-addressing", "explains", "family:the-correction-that-cannot-arrive")

# ---- refuse to emit a graph the checker would convict -----------------------
# The instrument fix, in the same breath as the defect: a dangling edge is now a
# failure AT GENERATION, not something discovered afterwards in the output.
_ids = {n["id"] for n in NODES}
_dangling = [e for e in EDGES if e["from"] not in _ids or e["to"] not in _ids]
if _dangling:
    for e in _dangling:
        side = "from" if e["from"] not in _ids else "to"
        print(f"  DANGLING  {e['from']} -{e['rel']}-> {e['to']}   ({side} is not a node)",
              file=sys.stderr)
    print(f"\n  refusing to emit: {len(_dangling)} edge(s) point at nothing.", file=sys.stderr)
    sys.exit(1)

# ------------------------------------------------------------------------ head
NODES.sort(key=lambda n: n["id"])
EDGES.sort(key=lambda e: (e["from"], e["rel"], e["to"]))
head = hashlib.sha256("".join(n["hash"] for n in NODES).encode()).hexdigest()

graph = {
  "$comment": (
    "A content-addressed graph of everything this household publishes. Every node "
    "carries the sha256 of its own canonical content; `head` is the hash over all "
    "of them, in id order. If your cached head matches, your copy is current and "
    "you need fetch nothing. If it does not, only the nodes whose hashes differ "
    "have changed. This exists because a correction cannot reach a copy that is "
    "addressed by LOCATION — the address stays valid while the bytes move under it."),
  "schema_version": "1.0",
  "record": "havens/graph",
  "generated": GENERATED,
  "canonical": BASE + "/havens.jsonld",
  "head_endpoint": BASE + "/havens.head",
  "head": head,
  "not_rdf": ("This is a plain typed graph, deliberately. There is no @context, "
              "because a half-implemented vocabulary advertises a capability it "
              "cannot keep — which is the exact failure class this record collects "
              "(see family:declared-not-measured). Map it to RDF properly, once, or "
              "not at all."),
  "schema": {
    "node": {"id": "stable, human-readable, never reused",
             "type": "one of: " + ", ".join(sorted({n["type"] for n in NODES})),
             "content": "the claim itself",
             "hash": "sha256 of the canonical JSON of `content` (sorted keys, no spacing)"},
    "edge": {"from": "node id", "rel": "typed relation", "to": "node id",
             "at": "ISO date, when the relation was asserted",
             "by": "who asserted it, when that matters"},
    "relations": sorted({e["rel"] for e in EDGES}),
    "how_to_verify": ("Recompute sha256 over the canonical JSON of any node's "
                      "`content` and compare to its `hash`. Recompute the head by "
                      "concatenating every node hash in id order and hashing that. "
                      "Nothing here asks to be trusted.")
  },
  "licence": "Public domain. No attribution required.",
  "counts": {"nodes": len(NODES), "edges": len(EDGES)},
  "nodes": NODES,
  "edges": EDGES,
}

io.open(os.path.join(ROOT, "havens.jsonld"), "w", encoding="utf-8").write(
    json.dumps(graph, indent=2, ensure_ascii=False))

head_doc = {"head": head, "generated": GENERATED,
            "nodes": len(NODES), "edges": len(EDGES),
            "graph": BASE + "/havens.jsonld",
            "note": "If this head matches the one you cached, your copy is current."}
if KG:
    # The cheap check now also carries the restore target, so a heal that can reach
    # this ~400-byte file never needs a hand-typed anchor. See note:known-good-deploy.
    head_doc["known_good_deploy"] = KG
    head_doc["known_good_verified_at"] = KG_AT
    head_doc["known_good_files"] = int(KG_FILES) if KG_FILES else None
    head_doc["anchor_rule"] = ("Derived from the host's published-deploy record at generation time, "
                               "never typed. A heal reads this first; if unreachable, asks the host for "
                               "its newest published 'ready' deploy; hardcoded ids only behind both.")
io.open(os.path.join(ROOT, "havens.head"), "w", encoding="utf-8").write(
    json.dumps(head_doc, indent=2) + "\n")

print(f"havens.jsonld  {len(NODES)} nodes, {len(EDGES)} edges, "
      f"{os.path.getsize(os.path.join(ROOT,'havens.jsonld')):,} B")
print(f"havens.head    {os.path.getsize(os.path.join(ROOT,'havens.head'))} B   head {head[:16]}…")
