File size: 11,029 Bytes
cf8eaa3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
#!/usr/bin/env python3
"""Forge a REAL trained surrogate for szl-provctl.
Kernel = ground truth. Surrogate = fast provenance-DAG anomaly classifier: given
graph-structural observables of a provenance DAG (node/edge counts, indegrees,
per-node chain-integrity recompute, edge-head-match recompute, orphan/cycle
signals), predict which anomaly class the kernel's `verify_dag(...)` would report.

The kernel itself is the labeler: `ProvenanceDAG.verify_dag()['first_break']['kind']`
maps to one of {clean, cycle, edge-mismatch, missing-dependency, chain-integrity}.
Each corrupted DAG injects EXACTLY one anomaly (single-aspect rule). A sample of
labels is re-audited by full kernel replay and MUST agree.
Seeded, receipted, reproducible."""
import json, os, random, sys, time, hashlib, platform
_here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if os.path.isdir(os.path.join(_here, "build", "torch-universal")):
    sys.path.insert(0, os.path.join(_here, "build", "torch-universal"))  # in-repo run
else:
    sys.path.insert(0, "/tmp/kernel-probe/szl-provctl/build/torch-universal")  # forge-dev run
import szl_provctl as pc
from szl_provctl._chain import UnifiedReceiptChain, GENESIS
from szl_provctl._dag import ProvenanceDAG, ProvenanceNode
import numpy as np
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, recall_score
import joblib

SEED = 20260721
random.seed(SEED); np.random.seed(SEED)
T0 = time.time()

# kernel first_break kinds -> our class names.
# NOTE (measured kernel limitation, honest): the kernel's `missing_dependency`
# verdict is UNREACHABLE — `_topo_order()` raises KeyError on any ghost edge
# BEFORE verify_dag's per-edge check runs, so no DAG can be labeled
# `missing_dependency` through the public API. We therefore do NOT include that
# class (we never fabricate a code path the kernel cannot actually produce).
KIND_TO_CLASS = {
    None: "clean",
    "cycle": "cycle",
    "edge_mismatch": "edge-mismatch",
    "chain_integrity": "chain-integrity",
}
CLASSES = ["clean", "cycle", "edge-mismatch", "chain-integrity"]


def make_chain(n_receipts):
    ch = UnifiedReceiptChain()
    for i in range(n_receipts):
        ch.emit(random.choice(["governed_norm", "lambda_gate", "govsign"]),
                random.choice(["rms_norm", "layer_norm", "gate_decision"]),
                {"seq_local": i, "eps": random.choice([1e-6, 1e-5]), "val": random.random()})
    return ch


def build_clean_dag(n_nodes):
    """A valid multi-run DAG: each node may depend on earlier nodes; heads asserted correctly."""
    dag = ProvenanceDAG()
    ids = [f"run{i}" for i in range(n_nodes)]
    for i, nid in enumerate(ids):
        ch = make_chain(random.randint(1, 4))
        deps = []
        if i > 0 and random.random() < 0.7:
            deps = random.sample(ids[:i], k=random.randint(1, min(2, i)))
        verdict = "BLOCKED" if random.random() < 0.12 else "ALLOWED"
        dag.add_run(nid, ch, deps=deps, verdict=verdict)
    return dag, ids


def corrupt(dag, ids, cls):
    """Inject EXACTLY one anomaly of type cls into a clean DAG (mutating node internals)."""
    nodes = dag._nodes
    if cls == "cycle":
        # create a 2-cycle: pick a node with a dep, add a reverse edge dep->that
        cands = [nid for nid in ids if nodes[nid].deps]
        if not cands:
            # force an edge then reverse it
            a, b = ids[0], ids[1]
            nodes[b].deps.append((a, nodes[a].head()))
            cands = [b]
        target = random.choice(cands)
        dep_id = nodes[target].deps[0][0]
        # add target as a dependency of dep_id -> cycle target<->dep_id
        nodes[dep_id].deps.append((target, nodes[target].head()))
    elif cls == "edge-mismatch":
        cands = [nid for nid in ids if nodes[nid].deps]
        if not cands:
            a, b = ids[0], ids[1]
            nodes[b].deps.append((a, nodes[a].head()))
            cands = [b]
        target = random.choice(cands)
        dep_id, _asserted = nodes[target].deps[0]
        # assert a WRONG head for the edge (single-aspect: only the asserted head changes)
        bad = hashlib.sha3_256(f"tamper{target}".encode()).hexdigest()
        nodes[target].deps[0] = (dep_id, bad)
    elif cls == "chain-integrity":
        # flip a byte inside one node's chain blob body digest (break internal chain)
        target = random.choice(ids)
        records = json.loads(nodes[target].chain_blob)
        if not records:
            ch = make_chain(2); nodes[target].chain_blob = ch.to_json()
            records = json.loads(nodes[target].chain_blob)
        j = random.randrange(len(records))
        d = bytearray(bytes.fromhex(records[j]["digest"])); d[0] ^= 0xFF
        records[j]["digest"] = bytes(d).hex()
        nodes[target].chain_blob = json.dumps(records, sort_keys=True, separators=(",", ":"))
    return dag


def features(dag):
    """Graph-structural observables + cheap per-node/edge recomputes."""
    nodes = dag._nodes
    ids = list(nodes.keys())
    n_nodes = len(ids)
    all_deps = [(nid, dep, head) for nid in ids for (dep, head) in nodes[nid].deps]
    n_edges = len(all_deps)
    indeg = {nid: len(nodes[nid].deps) for nid in ids}
    indeg_vals = list(indeg.values()) or [0]
    # missing-dependency signal: edges pointing at ids not in the DAG
    n_missing = sum(1 for (_n, dep, _h) in all_deps if dep not in nodes)
    # edge-mismatch signal: asserted head != actual head (for present deps)
    n_edge_mismatch = sum(1 for (_n, dep, h) in all_deps
                          if dep in nodes and h != nodes[dep].head())
    # chain-integrity signal: per-node internal chain verify
    n_chain_bad = 0
    for nid in ids:
        ok, _d, _b = UnifiedReceiptChain.verify_json(nodes[nid].chain_blob)
        if not ok:
            n_chain_bad += 1
    # cycle signal: detect a cycle via DFS on present-dep edges
    adj = {nid: [dep for (dep, _h) in nodes[nid].deps if dep in nodes] for nid in ids}
    has_cycle = _detect_cycle(adj)
    n_blocked = sum(1 for nid in ids if nodes[nid].verdict == "BLOCKED")
    return [
        n_nodes, n_edges,
        max(indeg_vals), float(np.mean(indeg_vals)),
        n_missing, n_edge_mismatch, n_chain_bad,
        int(has_cycle), n_blocked,
        float(n_edges) / max(n_nodes, 1),
    ]


def _detect_cycle(adj):
    WHITE, GRAY, BLACK = 0, 1, 2
    color = {n: WHITE for n in adj}
    def dfs(u):
        color[u] = GRAY
        for v in adj.get(u, []):
            if color.get(v, WHITE) == GRAY:
                return True
            if color.get(v, WHITE) == WHITE and dfs(v):
                return True
        color[u] = BLACK
        return False
    return any(color[n] == WHITE and dfs(n) for n in adj)


FEATURE_NAMES = ["n_nodes", "n_edges", "max_indegree", "mean_indegree",
                 "n_missing_deps", "n_edge_mismatch", "n_chain_bad",
                 "has_cycle", "n_blocked_nodes", "edge_density"]


def kernel_class(dag):
    """Ground truth: run the kernel verify_dag and map first_break.kind to a class."""
    rep = pc.verify_dag(dag)
    if rep["ok"]:
        return "clean"
    kind = (rep.get("first_break") or {}).get("kind")
    return KIND_TO_CLASS.get(kind, kind if kind in CLASSES else "chain-integrity")


def make_dag(cls):
    """Build a DAG whose kernel-derived label is GUARANTEED to equal cls."""
    for _ in range(60):
        n = random.randint(3, 8)
        dag, ids = build_clean_dag(n)
        if cls != "clean":
            corrupt(dag, ids, cls)
        derived = kernel_class(dag)
        if derived == cls:
            return dag
    raise RuntimeError(f"could not synthesize a kernel-confirmed {cls} DAG")


# ---- generate ----
X, y = [], []
dags = []
PER_CLASS = {c: (2600 if c == "clean" else 1900) for c in CLASSES}
plan = []
for c, k in PER_CLASS.items():
    plan += [c] * k
random.shuffle(plan)
for cls in plan:
    dag = make_dag(cls)
    X.append(features(dag)); y.append(cls); dags.append((dag, cls))

# ground-truth audit: full kernel replay must agree
audit_checked = 0
for i in random.sample(range(len(dags)), 700):
    dag, cls = dags[i]
    assert kernel_class(dag) == cls, f"kernel disagrees DAG {i}: {kernel_class(dag)} != {cls}"
    audit_checked += 1

X = np.array(X, dtype=np.float64); y = np.array(y)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.2, random_state=SEED, stratify=y)
clf = HistGradientBoostingClassifier(random_state=SEED, max_iter=300, early_stopping=True)
clf.fit(Xtr, ytr)
pred = clf.predict(Xte)
acc = accuracy_score(yte, pred)
per_class_recall = {c: float(recall_score(yte == c, pred == c, zero_division=0)) for c in CLASSES}

out_dir = os.path.dirname(os.path.abspath(__file__))
joblib.dump(clf, f"{out_dir}/model.joblib")
model_sha = hashlib.sha256(open(f"{out_dir}/model.joblib", "rb").read()).hexdigest()
receipt = {
  "artifact": "SZLHOLDINGS/szl-provctl surrogate v1",
  "role": "provenance-DAG anomaly classifier surrogate — kernel remains ground truth",
  "generator": {"script": "scripts/forge.py", "seed": SEED, "kernel_version": pc.__version__,
                 "kernel_labelled": True, "kernel_audited_dags": audit_checked,
                 "labeler": "ProvenanceDAG.verify_dag()['first_break']['kind']"},
  "data": {"rows": int(len(y)), "classes": CLASSES,
            "class_counts": {c: int((y == c).sum()) for c in CLASSES},
            "split": "80/20 stratified", "features": FEATURE_NAMES,
            "feature_policy": "graph-structural observables + cheap per-node chain recompute + edge-head-match recompute; the surrogate never replaces the kernel's full verify_dag traversal"},
  "model": {"type": "sklearn.HistGradientBoostingClassifier",
             "params": {"max_iter": 300, "early_stopping": True, "random_state": SEED},
             "file": "model.joblib", "sha256": model_sha},
  "metrics_MEASURED": {"fidelity_vs_kernel_heldout": round(float(acc), 4),
                        "test_accuracy": round(float(acc), 4),
                        "per_class_recall": {k: round(v, 4) for k, v in per_class_recall.items()}},
  "environment": {"python": platform.python_version(), "sklearn": __import__("sklearn").__version__,
                   "numpy": np.__version__, "host": "replit 2-vCPU container",
                   "wall_seconds": round(time.time() - T0, 1)},
  "honesty": "Every number above is MEASURED by this run. Fidelity = agreement%% with the kernel's ProvenanceDAG.verify_dag anomaly verdict on a held-out split. The surrogate is a fast triage; the kernel's full DAG traversal + in-toto/SLSA interop stay authoritative. Λ untouched = Conjecture 1.",
  "trained_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
with open(f"{out_dir}/TRAINING_RECEIPT.json", "w") as f:
    json.dump(receipt, f, indent=2)
print(json.dumps(receipt["metrics_MEASURED"], indent=2))
print(f"rows={len(y)} kernel_audited_dags={audit_checked} wall={receipt['environment']['wall_seconds']}s")