← Files APODICTIC Development EditorARCHIVED FILE

scripts/author_fingerprint.py

23.9 KB · Nov 9, 2026 · 00:53 UTC

↓ Download file

#!/usr/bin/env python3
"""author-fingerprint — structural integrity for the Cross-Manuscript Author Voice/Craft Fingerprint.

`validate.sh author-fingerprint <author_root|files>` shells out here. A writer's voice changes over a
career — by growth, by drift, or by an unconscious settling into the same cadence book after book.
APODICTIC measures voice WITHIN a manuscript but has no memory ACROSS works; this is that memory: a
persistent profile, collected under an operator-designated author-root, that surfaces movement. It
does NO new stylometry — it consumes the single-voice AI-prose machinery (voice_profile /
voice_distance + personal-baseline z-scores) and adds the persistence-and-diagnosis layer. Each work
is an apodictic.voice_fingerprint.v1 block; this validator owns the cross-work profile contract.

The module is descriptive (observations, not verdicts) and local-only (no external call). It compares
drift ONLY within a register (the AI-prose domain-shift guard) and never prescribes a voice change.

  F1 schema        a voice_fingerprint block fails its schema (bad source enum, malformed VF-… id,
                   missing work_label/register/source/metrics, broken JSON, duplicate id, or a
                   `metrics` that is empty or carries a non-scalar value).
  F2 provenance    a fingerprint with no `centroid_ref` naming a consumed audit output — the module
                   consumes; a fingerprint that names no source value cannot be traced (ERROR).
  F3 same-register a drift/range claim (a line referencing >= 2 fingerprint ids) whose fingerprints
                   do not all share a `register` — a cross-register comparison the domain-shift
                   caution forbids (ERROR). Bespoke prose-claim parse (the continuity-bible C3 shape).
  F4 descriptive   the reader-facing prose carries an editorial Must/Should/Could severity token or a
                   prescriptive voice-directive ("you should tighten your sentences", "fix your
                   voice") — a fingerprint reports movement, it never prescribes or grades. Advisory;
                   ERROR under --strict. Override: <!-- override: fingerprint-frame VF-… — <why> -->.
  W1 insufficient  no register has >= 2 fingerprints, so no drift/growth/self-imitation diagnosis is
     data         possible — the profile is seed-only (a first work legitimately just seeds).
                   Advisory; ERROR under --strict.
  W2 local-only    the profile lacks a `<!-- author-voice-profile: local-only -->` marker, or it
                   references an external http(s) URL. Advisory WARN ONLY (a self-attestation hygiene
                   check — it cannot detect a runtime call; the binding guarantee is the module's
                   no-external-call rule, not this scan), never escalated under --strict.

Reuses apodictic_artifacts (block grammar + schema engine). The profile is a single artifact (no
paired ledger); an empty/absent one is a no-op. See docs/author-voice-fingerprint.md.

  author_fingerprint.py author-fingerprint <author_root|files...> [--strict]
  author_fingerprint.py --self-test

Exit: 0 clean / WARN-only, 1 ERROR (or WARN under --strict, EXCEPT W2 which stays WARN), 2 usage.
"""
import glob
import os
import re
import sys

from override_marker import override_targets  # SSoT: code-span-stripped, boundary-matched override scan
from severity_vocab import SEVERITY_TOKEN_RE  # SSoT: the editorial Must/Should/Could-Fix leak token

try:
    import apodictic_artifacts as art
except ImportError:
    art = None

_SCHEMA_ID = "apodictic.voice_fingerprint.v1"
_PROFILE_GLOB = "Author_Voice_Profile*.md"

_VF_REF_RE = re.compile(r"\bVF-[A-Za-z0-9-]+\b")
_HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
_LOCAL_ONLY_RE = re.compile(r"<!--\s*author-voice-profile:\s*local-only\s*-->", re.IGNORECASE)
_EXTERNAL_URL_RE = re.compile(r"https?://", re.IGNORECASE)
# The editorial Must/Should/Could-Fix leak token; the shared severity_vocab SSoT (M8).
_SEVERITY_RE = SEVERITY_TOKEN_RE
# F4 — a prescriptive directive aimed at the author's voice (the module describes, never prescribes).
_PRESCRIPTIVE_RE = re.compile(
    r"\byou\s+(?:should|must|need\s+to|ought\s+to|have\s+to|'?d\s+better)\b"
    r"|\bfix\s+your\s+(?:voice|cadence|prose|style|sentences)\b"
    r"|\b(?:vary|tighten|loosen|expand|broaden|change|diversify|fix)\s+your\s+(?:voice|cadence|prose|style|sentences|range)\b",
    re.IGNORECASE)
# Fingerprint-frame overrides (`<!-- override: fingerprint-frame VF-… -->`) route through the shared
# `override_marker` SSoT — code spans stripped, slug boundary-matched.


def _read(path):
    try:
        with open(path, encoding="utf-8") as fh:
            return fh.read()
    except (OSError, UnicodeDecodeError):
        return None


def _overrides(text, slug):
    """The set of VF-… ids overridden for `slug` (`fingerprint-frame`) — via the shared SSoT, so a
    marker quoted inside a code span is not honored as a live directive."""
    return {t[0] for t in override_targets(text, slug, r"(VF-[A-Za-z0-9-]+)")}


def parse_fingerprints(text):
    """[(obj_or_None, schema_errs, index), ...] for each apodictic:voice_fingerprint block."""
    out = []
    if not text or art is None:
        return out
    schema = art.load_schema(_SCHEMA_ID)
    idx = 0
    for btype, obj, jerr in art.parse_blocks(text):
        if btype != "voice_fingerprint":
            continue
        idx += 1
        where = "voice_fingerprint #%d" % idx
        if jerr:
            out.append((None, ["%s: invalid JSON — %s" % (where, jerr)], idx))
            continue
        errs = art.validate_obj(obj, schema, where)
        # metrics: non-empty map of scalar values (the subset engine only types the container). The
        # contract (schema $comment + F1) is scalar == str/number/bool; check it as an ALLOWLIST, not
        # a "not dict/list" blocklist — a blocklist passed JSON `null`, which is not a scalar and would
        # read as a valid-but-uninformative metric (and crash any consumer that does arithmetic on it).
        metrics = obj.get("metrics") if isinstance(obj, dict) else None
        if isinstance(metrics, dict):
            if not metrics:
                errs.append("%s: 'metrics' is empty (need >= 1 consumed metric)" % where)
            for k, v in metrics.items():
                if not isinstance(v, (str, int, float)):  # bool is an int subclass; null/dict/list out
                    errs.append("%s: metrics.%s must be a scalar value (str/number/bool), not %s"
                                % (where, k, type(v).__name__))
        out.append((obj, errs, idx))
    return out


def _claim_lines(text):
    """Visible-prose lines that reference >= 2 distinct fingerprint ids (a comparison claim)."""
    out = []
    visible = _HTML_COMMENT_RE.sub("", text or "")  # drop the fingerprint blocks (HTML comments)
    for raw in visible.splitlines():
        refs = []
        for r in _VF_REF_RE.findall(raw):
            if r not in refs:
                refs.append(r)
        if len(refs) >= 2:
            out.append(refs)
    return out


def fingerprint_profile(text, strict=False):
    """Run the Author Voice Profile integrity checks. Returns (code, lines)."""
    lines, errs, warns, w2 = [], [], [], []
    fps = parse_fingerprints(text)
    if not fps:
        return 0, ["author-fingerprint: no voice_fingerprint blocks found — nothing to check"]

    # F1 — schema / JSON / metrics validity
    for _obj, schema_errs, _idx in fps:
        for e in schema_errs:
            errs.append("F1 schema: %s" % e)

    valid = [(obj, idx) for obj, schema_errs, idx in fps if obj is not None and not schema_errs]
    # F1 — register (and work_label) must be non-blank. The schema's plain `type: string` accepts "",
    # and two blank-register fingerprints would collide as one register — suppressing W1 and passing
    # F3 with no comparability class actually supplied (Codex P1). A blank-field fingerprint is treated
    # as invalid so the same-register / coverage checks never run on it.
    nonblank = []
    for obj, idx in valid:
        blanks = [f for f in ("register", "work_label") if not (obj.get(f) or "").strip()]
        if blanks:
            errs.append("F1 schema: %s has a blank %s — required and non-empty (register names the "
                        "comparability class; an empty value silently collides with other blanks)"
                        % (obj.get("id"), "/".join(blanks)))
        else:
            nonblank.append((obj, idx))
    valid = nonblank
    by_id, seen = {}, {}
    for obj, idx in valid:
        seen.setdefault(obj.get("id"), []).append(idx)
        by_id.setdefault(obj.get("id"), obj)
    for vid, where in sorted(seen.items()):
        if len(where) > 1:
            errs.append("F1 schema: %s appears %d times (ids must be unique)" % (vid, len(where)))

    # F2 — provenance: every fingerprint names a consumed audit output via centroid_ref
    for obj, _idx in valid:
        if not (obj.get("centroid_ref") or "").strip():
            errs.append("F2 provenance: %s has no centroid_ref naming a consumed audit output — the "
                        "module consumes existing stylometry; an un-sourced fingerprint can't be traced"
                        % obj.get("id"))

    # F3 — same-register comparison (a claim referencing >= 2 fingerprints must share a register)
    for refs in _claim_lines(text):
        # A referenced-but-unknown VF-id is an integrity error, not a silent skip: discarding it let
        # a typo ("VF-lit differs from VF-typo") hide the intended cross-register comparison and still
        # PASS (Codex P1). Mirror continuity-bible C3's unknown-id guard; only run the same-register
        # check once every ref resolves.
        unknown = [r for r in refs if r not in by_id]
        if unknown:
            errs.append("F3 reference: a drift/range claim references unknown fingerprint id(s) %s — "
                        "every VF-… in a claim must resolve (a typo can otherwise hide the comparison)"
                        % ", ".join(sorted(unknown)))
            continue
        registers = {(by_id[r].get("register") or "").strip().lower() for r in refs}
        if len(registers) > 1:
            errs.append("F3 same-register: a drift/range claim compares fingerprints across registers "
                        "(%s) — drift is only meaningful within a register (the AI-prose domain-shift "
                        "guard)" % ", ".join(sorted(r for r in registers if r)))

    # F4 — descriptive, not prescriptive / defect (advisory; ERROR --strict; override silences)
    visible = _HTML_COMMENT_RE.sub("", text or "")
    if not _overrides(text, "fingerprint-frame"):
        if _SEVERITY_RE.search(visible):
            warns.append("F4 descriptive: the profile carries an editorial Must/Should/Could-Fix "
                         "severity token — a fingerprint is not a defect and takes no editorial severity")
        if _PRESCRIPTIVE_RE.search(visible):
            warns.append("F4 descriptive: the profile prescribes a voice change ('fix/vary your "
                         "voice', 'you should …') — the module reports movement, it never prescribes")

    # W1 — insufficient data (no register has >= 2 fingerprints → seed-only)
    reg_counts = {}
    for obj, _idx in valid:
        reg_counts[(obj.get("register") or "").strip().lower()] = \
            reg_counts.get((obj.get("register") or "").strip().lower(), 0) + 1
    if valid and max(reg_counts.values()) < 2:
        warns.append("W1 insufficient data: no register has >= 2 fingerprints — the profile is "
                     "seed-only; drift / growth / self-imitation diagnosis is suppressed")

    # W2 — local-only hygiene (advisory WARN ONLY, never escalated under --strict)
    if not _LOCAL_ONLY_RE.search(text or ""):
        w2.append("W2 local-only: the profile lacks a `<!-- author-voice-profile: local-only -->` "
                  "marker — a career voice profile must never be transmitted (hygiene attestation)")
    if _EXTERNAL_URL_RE.search(visible):
        w2.append("W2 local-only: the profile references an external http(s) URL — the profile is "
                  "local-only and makes no external call")

    # Report
    lines.append("author-fingerprint: %d fingerprint(s)%s" % (
        len(fps), "" if len(valid) == len(fps) else " (%d well-formed)" % len(valid)))
    for obj, _idx in valid:
        lines.append("  %-18s register=%-18s source=%s" % (obj.get("id"), obj.get("register"),
                                                           obj.get("source")))
    for e in errs:
        lines.append("  ERROR: %s" % e)
    for w in warns:
        lines.append("  %s: %s" % ("ERROR (--strict)" if strict else "WARN", w))
    for w in w2:
        lines.append("  WARN: %s" % w)  # W2 stays WARN even under --strict

    if errs or (strict and warns):
        lines.append("author-fingerprint: FAIL (%d error(s)%s)"
                     % (len(errs), ", %d strict warn(s)" % len(warns) if (strict and warns) else ""))
        return 1, lines
    if warns or w2:
        lines.append("WARN: author-fingerprint: %d advisory gap(s) — see F4/W1/W2 above"
                     % (len(warns) + len(w2)))
    else:
        lines.append("author-fingerprint: PASS (schema + provenance + same-register + "
                     "descriptive-not-prescriptive)")
    return 0, lines


# ---------------------------------------------------------------- resolution

def _newest(paths):
    return max(paths, key=os.path.getmtime) if paths else None


def _has_block(text, btype):
    """True if `text` carries a real apodictic:<btype> block (a parsed carrier, not a prose mention).

    Classifying on parsed blocks — not a raw substring — keeps a docs/spec file that merely *names*
    `apodictic:voice_fingerprint` in prose from winning resolution over the real profile."""
    if art is None:
        return ("apodictic:%s" % btype) in (text or "")  # degraded: no engine to parse with
    return any(bt == btype for bt, _o, _e in art.parse_blocks(text or ""))


def resolve(paths):
    if len(paths) == 1 and os.path.isdir(paths[0]):
        return _newest(glob.glob(os.path.join(paths[0], _PROFILE_GLOB)))
    for p in paths:
        if _has_block(_read(p) or "", "voice_fingerprint"):
            return p
    return paths[0] if paths else None


def run(paths, strict=False):
    path = resolve(paths)
    if not path:
        return 2, ["author-fingerprint: no Author Voice Profile found (need an Author_Voice_Profile*.md "
                   "or a file with apodictic:voice_fingerprint blocks)"]
    text = _read(path)
    if text is None:
        return 2, ["author-fingerprint: cannot read %s" % path]
    return fingerprint_profile(text, strict=strict)


# ---------------------------------------------------------------- self-test

def run_self_test():
    import json as _j
    rc = {"v": 0}

    def chk(name, cond):
        print("  %s: %s" % (name, "OK" if cond else "FAIL"))
        if not cond:
            rc["v"] = 1

    # non-UTF8 artifact: _read must degrade to the unreadable path (None), never
    # a traceback (the disposition_check adjacent-exception class, swept repo-wide)
    import tempfile as _tf
    _fd, _nu = _tf.mkstemp(suffix=".md")
    with os.fdopen(_fd, "wb") as _fh:
        _fh.write(b"\xff\xfenot utf-8\xff")
    chk("non_utf8_read_returns_none", _read(_nu) is None)
    os.unlink(_nu)

    LOCAL = "<!-- author-voice-profile: local-only -->\n\n"

    def fp(vid, register="literary-fiction", source="ai-prose-baseline",
           centroid_ref="results.baseline_comparison", work_label="Thornfield (2026)",
           metrics=None):
        obj = {"schema": _SCHEMA_ID, "id": vid, "work_label": work_label, "register": register,
               "source": source, "centroid_ref": centroid_ref,
               "metrics": metrics if metrics is not None else {"mattr_z": "-0.4", "burrows_to_author_centroid": "0.7"}}
        return "<!-- apodictic:voice_fingerprint\n%s\n-->" % _j.dumps(obj)

    TWO = (fp("VF-2024-marsh", work_label="Marsh Light (2024)", metrics={"burrows_to_author_centroid": "0.2"})
           + "\n" + fp("VF-2026-thornfield", metrics={"burrows_to_author_centroid": "0.7"}))

    # clean: two same-register fingerprints + local-only marker, no prescriptive prose
    chk("clean_two_same_register", fingerprint_profile(LOCAL + TWO)[0] == 0)

    # F1 — schema
    chk("f1_bad_source", fingerprint_profile(LOCAL + fp("VF-01", source="pov-centroid"))[0] == 1)
    chk("f1_bad_id", fingerprint_profile(LOCAL + fp("VF 01"))[0] == 1)
    chk("f1_missing_field", fingerprint_profile(LOCAL + fp("VF-01").replace('"register"', '"reg"'))[0] == 1)
    chk("f1_metrics_empty", fingerprint_profile(LOCAL + fp("VF-01", metrics={}))[0] == 1)
    chk("f1_metrics_nonscalar", fingerprint_profile(LOCAL + fp("VF-01", metrics={"x": {"nested": 1}}))[0] == 1)
    chk("f1_metrics_nonscalar_list", fingerprint_profile(LOCAL + fp("VF-01", metrics={"x": [1, 2]}))[0] == 1)
    # a JSON `null` metric value is NOT a scalar (str/number/bool) — the contract is an allowlist, so
    # null must be rejected like dict/list (a blocklist "not dict/list" wrongly passed it — P2).
    chk("f1_metrics_null_rejected",
        fingerprint_profile(LOCAL + fp("VF-01", metrics={"x": None}))[0] == 1)
    # a numeric or bool scalar metric is valid (the allowlist must not over-reject legitimate scalars)
    chk("f1_metrics_numeric_scalar_clean",
        fingerprint_profile(LOCAL + fp("VF-01", metrics={"mattr_z": -0.4, "tic": True}) + "\n" + fp("VF-02"))[0] == 0)
    code, lines = fingerprint_profile(LOCAL + '<!-- apodictic:voice_fingerprint\n{"schema":"apodictic.voice_fingerprint.v1"\n-->')
    chk("f1_bad_json", code == 1 and any("F1 schema" in ln for ln in lines))
    code, lines = fingerprint_profile(LOCAL + fp("VF-01") + "\n" + fp("VF-01", register="memoir"))
    chk("f1_duplicate_id", code == 1 and any("appears 2 times" in ln for ln in lines))
    # two blank-register fingerprints must be REJECTED — empty registers would collide as one and
    # suppress W1 / pass F3 with no comparability class supplied (Codex P1)
    code, lines = fingerprint_profile(LOCAL + fp("VF-e1", register="")
                                      + "\n" + fp("VF-e2", register=""))
    chk("f1_blank_register_rejected", code == 1 and any("blank register" in ln for ln in lines))

    # F2 — provenance (centroid_ref present)
    code, lines = fingerprint_profile(LOCAL + fp("VF-01", centroid_ref="") + "\n" + fp("VF-02"))
    chk("f2_missing_centroid_ref", code == 1 and any("F2 provenance" in ln for ln in lines))

    # F3 — same-register (a claim referencing cross-register fingerprints)
    MIXED = (fp("VF-lit", register="literary-fiction") + "\n"
             + fp("VF-mem", register="memoir", work_label="A Quiet Year (2025)"))
    drift_cross = "\n## Drift\n\n- VF-lit has moved 0.8 from VF-mem across the two works.\n"
    code, lines = fingerprint_profile(LOCAL + MIXED + drift_cross)
    chk("f3_cross_register_claim", code == 1 and any("F3 same-register" in ln for ln in lines))
    # a same-register claim is clean
    drift_same = "\n## Drift\n\n- VF-2026-thornfield sits 0.5 further from the centroid than VF-2024-marsh.\n"
    chk("f3_same_register_claim_clean",
        not any("F3" in ln for ln in fingerprint_profile(LOCAL + TWO + drift_same)[1]))
    # a comparison naming an UNKNOWN id is an integrity error, not a silent skip: a typo must not hide
    # the intended cross-register comparison (Codex P1)
    typo = "\n## Drift\n\n- VF-lit differs from VF-typo across the two works.\n"
    code, lines = fingerprint_profile(LOCAL + MIXED + typo)
    chk("f3_dangling_ref_errors", code == 1 and any("F3 reference" in ln for ln in lines))

    # F4 — descriptive, not prescriptive (advisory; ERROR --strict; override silences)
    sev = LOCAL + TWO + "\n## Notes\n\n- This narrowing is a Must-Fix for the next book.\n"
    code, lines = fingerprint_profile(sev)
    chk("f4_severity_token_advisory", code == 0 and any("F4 descriptive" in ln for ln in lines))
    chk("f4_severity_token_strict_fails", fingerprint_profile(sev, strict=True)[0] == 1)
    presc = LOCAL + TWO + "\n## Notes\n\n- You should vary your cadence in the next book.\n"
    chk("f4_prescriptive_advisory", any("F4 descriptive" in ln for ln in fingerprint_profile(presc)[1]))
    ov = "<!-- override: fingerprint-frame VF-2026-thornfield — quoting the author's own goal -->\n"
    chk("f4_override_silences", not any("F4" in ln for ln in fingerprint_profile(ov + sev)[1]))
    # code-span decoy (bypass closed by the SSoT migration): an override quoted inside a code span is a
    # documentation example, not a live directive — F4 must still fire, in EITHER CommonMark form.
    chk("f4_inline_codespan_override_does_not_silence",
        any("F4" in ln for ln in fingerprint_profile("`" + ov.strip() + "`\n" + sev)[1]))
    chk("f4_fenced_codespan_override_does_not_silence",
        any("F4" in ln for ln in fingerprint_profile("```\n" + ov.strip() + "\n```\n" + sev)[1]))
    # a plain descriptive observation does not trip F4
    chk("f4_descriptive_clean",
        not any("F4" in ln for ln in fingerprint_profile(
            LOCAL + TWO + "\n## Drift\n\n- The voice has tightened across the two works — intended, or drift?\n")[1]))

    # W1 — insufficient data (one fingerprint, or two in different registers => no >=2 same-register)
    code, lines = fingerprint_profile(LOCAL + fp("VF-01"))
    chk("w1_single_seed_advisory", code == 0 and any("W1 insufficient data" in ln for ln in lines))
    chk("w1_two_different_registers_seed",
        any("W1 insufficient data" in ln for ln in fingerprint_profile(LOCAL + MIXED)[1]))
    chk("w1_strict_fails", fingerprint_profile(LOCAL + fp("VF-01"), strict=True)[0] == 1)
    chk("w1_two_same_register_clean", not any("W1" in ln for ln in fingerprint_profile(LOCAL + TWO)[1]))

    # W2 — local-only hygiene (advisory WARN only, NEVER escalated under --strict)
    code, lines = fingerprint_profile(TWO)  # no local-only marker
    chk("w2_missing_marker_advisory", code == 0 and any("W2 local-only" in ln for ln in lines))
    chk("w2_never_strict_escalates", fingerprint_profile(TWO, strict=True)[0] == 0)
    url = LOCAL + TWO + "\n\nSee https://example.com/profile for the hosted copy.\n"
    chk("w2_external_url", any("external http(s) URL" in ln for ln in fingerprint_profile(url)[1]))

    # no blocks -> no-op
    chk("no_fingerprints_noop", fingerprint_profile("# Notes\nnothing structured\n")[0] == 0)

    # run-folder + explicit-file resolution
    import tempfile
    import shutil
    d = tempfile.mkdtemp()
    try:
        p = os.path.join(d, "Author_Voice_Profile.md")
        with open(p, "w", encoding="utf-8", newline="") as fh:
            fh.write("# Author Voice Profile\n" + LOCAL + TWO + "\n")
        chk("run_folder_resolution", run([d])[0] == 0)
        chk("explicit_file_resolution", run([p])[0] == 0)
        chk("missing_artifact_usage", run([os.path.join(d, "nope.md")])[0] == 2)
        # a decoy file that only NAMES apodictic:voice_fingerprint in prose must not win resolution
        # over the real profile (classify on parsed blocks, not raw substring)
        decoy = os.path.join(d, "spec_mention.md")
        with open(decoy, "w", encoding="utf-8", newline="") as fh:
            fh.write("# Notes\nThis spec describes the apodictic:voice_fingerprint block format.\n")
        chk("resolver_skips_prose_mention", run([decoy, p])[0] == 0
            and resolve([decoy, p]) == p)
    finally:
        shutil.rmtree(d, ignore_errors=True)

    print("Self-test: PASS" if rc["v"] == 0 else "Self-test: FAIL")
    return rc["v"]


def main(argv):
    if "--self-test" in argv:
        return run_self_test()
    args = [a for a in argv[1:] if a != "author-fingerprint"]
    strict = "--strict" in args
    paths = [a for a in args if not a.startswith("--")]
    if not paths:
        print("Usage: author_fingerprint.py author-fingerprint <author_root|files...> [--strict] | --self-test")
        return 2
    code, lines = run(paths, strict=strict)
    for ln in lines:
        print(ln)
    return code


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

SHA-256: 887a9d3917169caa00868bb81161014178ee4c0f165af479e57de0ea36645fa0