File size: 6,501 Bytes
f07443e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
"""Readable concept keys β€” `TYPE_SUBJECT`, the label a person or an agent searches by.

Asked for in the 2026-09-08 discussion (m1). The shape is the one agreed there:
the type first, then what the entry is *about*, in capitals β€” `GLOSSARY_PA`,
never the entry's full text. Two properties it exists to provide:

- **One concept, one key.** `PA` drawn from five documents still reads
  `GLOSSARY_PA`, so an agent that needs a term's definition writes the key and
  looks it up directly β€” no scan, no embedding, no model call.
- **Readable at a glance.** Capitals were a deliberate call (lowercase reads
  worse in a list), and lookups are capitals too.

**A key is a label, never an identity β€” this is m2.** `entity_id` in `ids.py`
stays the thing an expert's approval hangs on, because it is a hash of content
and survives a re-run untouched. A key is built from a *surface*, so it moves
the moment clustering canonicalises that surface differently. Both travel
together and neither substitutes for the other: the hash identifies, the key
reads. Collapsing the two is exactly how approvals get orphaned, which is the
failure `ids.py` was written to prevent.

Prefixes use the `kind` column's own vocabulary (English, singular) so the
system holds one spelling of each branch rather than two to keep in sync
(decided 2026-09-14).
"""

from __future__ import annotations

import hashlib
import re
import unicodedata
from collections.abc import Iterable, Mapping
from typing import Any

# Keyed by the `kind` column's values, so a new kind that forgets to register
# here returns None rather than silently inventing a prefix.
_PREFIX: dict[str, str] = {
    "glossary": "GLOSSARY",
    "rule": "RULE",
    "formula": "FORMULA",
    "document": "DOCUMENT",
    "domain": "DOMAIN",
}

# Past this a subject stops being a label and becomes the content it was meant
# to stand in for. A rule's `statement` can be a whole paragraph.
MAX_SUBJECT = 40

# Short, and deliberately NOT the full `entity_id`: this digest separates two
# rules that govern the same term. It does not identify the row β€” `entity_id`
# does, and shortening a hash for readability is only safe because of that.
DIGEST_LEN = 6


def slugify(surface: str) -> str:
    """`Physical of Availability (PA)` -> `PHYSICAL_OF_AVAILABILITY_PA`.

    Accents are folded rather than dropped so an Indonesian or a borrowed term
    keeps its letters. Everything that is not a letter or a digit becomes a
    single underscore, because a key that carries brackets or slashes cannot be
    typed from memory β€” which is the whole point of having one.
    """
    folded = unicodedata.normalize("NFKD", surface or "")
    folded = "".join(c for c in folded if not unicodedata.combining(c))
    slug = re.sub(r"[^A-Za-z0-9]+", "_", folded).upper()
    slug = re.sub(r"_+", "_", slug).strip("_")
    if len(slug) <= MAX_SUBJECT:
        return slug
    # Cut on a word boundary so the tail is not half a word β€” but a single long
    # token has no boundary to cut on, and an empty subject is worse than a
    # blunt one.
    cut = slug[:MAX_SUBJECT]
    head, _, _tail = cut.rpartition("_")
    return (head or cut).strip("_")


def _digest(value: str) -> str:
    return hashlib.sha256(value.encode("utf-8")).hexdigest()[:DIGEST_LEN].upper()


def term_surfaces(glossary_payloads: Iterable[Mapping[str, Any]] | None) -> dict[str, str]:
    """`term_id` -> the term's surface, for keying the rules that govern it.

    Built by the caller from the same run's glossary payloads, because a rule
    payload carries `term_ids` and no surfaces β€” the words live on the glossary
    entry, not on the rule.
    """
    out: dict[str, str] = {}
    for payload in glossary_payloads or []:
        term_id = payload.get("term_id")
        term = payload.get("term")
        if term_id and isinstance(term, str) and term.strip():
            out[str(term_id)] = term.strip()
    return out


def _rule_subject(
    payload: Mapping[str, Any], entity_id: str, surfaces: Mapping[str, str]
) -> str:
    """A rule has no canonical surface of its own β€” it is a sentence.

    So it borrows the term it governs and adds a short digest, because one term
    usually governs several rules: `RULE_PA_C07BE4`. Asking the model to name
    the rule was the obvious alternative and is the one `ids.py` already rules
    out β€” a model cannot produce a stable identifier, and a model-written
    `rule_id` was a real defect here, returning a different slug for the same
    rule on consecutive runs at temperature 0. A rule that governs no term
    keeps the digest alone; that it is not searchable by word was accepted when
    this was agreed.
    """
    for term_id in payload.get("term_ids") or []:
        surface = slugify(surfaces.get(str(term_id), ""))
        if surface:
            return f"{surface}_{_digest(entity_id)}"
    return _digest(entity_id)


def _subject(
    kind: str,
    payload: Mapping[str, Any],
    entity_id: str,
    doc_id: str,
    surfaces: Mapping[str, str],
) -> str:
    if kind == "glossary":
        return slugify(payload.get("term") or "")
    if kind == "formula":
        # A formula may legitimately have no name; `entity_id` already falls
        # back name -> latex -> chunk_id, so the digest inherits that work.
        return slugify(payload.get("name") or "") or _digest(entity_id)
    if kind == "document":
        # The document's own id reads better than its title. A title is a
        # verbatim transcription and routinely carries the PDF's broken
        # spacing β€” correct to store, useless to type.
        return slugify(doc_id) or _digest(entity_id)
    if kind == "domain":
        return slugify(payload.get("domain_name") or "")
    if kind == "rule":
        return _rule_subject(payload, entity_id, surfaces)
    return ""


def concept_key(
    kind: str,
    payload: Mapping[str, Any],
    *,
    entity_id: str,
    doc_id: str = "",
    surfaces: Mapping[str, str] | None = None,
) -> str | None:
    """The `TYPE_SUBJECT` key for one entry; None when the kind is unregistered.

    Never raises: the caller writes this into a row alongside content it has
    already validated, and a key that cannot be built is a degraded label, not
    a reason to lose the entry.
    """
    prefix = _PREFIX.get(kind)
    if prefix is None:
        return None
    subject = _subject(kind, payload, entity_id, doc_id, surfaces or {})
    return f"{prefix}_{subject}" if subject else prefix