Skip to content

Commit a122ab4

Browse files
committed
trace ace: add V95 objective-support activation separator
1 parent f05a11b commit a122ab4

2 files changed

Lines changed: 314 additions & 0 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: Trace the Ace V95 objective support
2+
on:
3+
push:
4+
branches: [agent/trace-ace-mastery-events]
5+
paths:
6+
- 'competitions/trace_the_ace/v95_objective_support_activation.py'
7+
- '.github/workflows/trace-ace-v95-objective-support.yml'
8+
workflow_dispatch:
9+
jobs:
10+
v95:
11+
runs-on: ubuntu-latest
12+
timeout-minutes: 360
13+
steps:
14+
- uses: actions/checkout@v4
15+
- uses: actions/setup-python@v5
16+
with:
17+
python-version: '3.12'
18+
- name: Install dependencies
19+
run: pip install numpy pandas scipy scikit-learn gdown
20+
- name: Download data
21+
run: |
22+
gdown 1nOjremWhpZ_QKSLvZfGcNkS_C3kMMBUI -O transcripts.zip
23+
gdown 1EpqoamY0vFI2qE57R6wdqU5HwuoVk3Zz -O metadata.zip
24+
mkdir -p data/transcripts data/meta
25+
unzip -q transcripts.zip -d data/transcripts
26+
unzip -q metadata.zip -d data/meta
27+
- name: Run V95
28+
shell: bash
29+
run: |
30+
FEATURES=$(find data/meta -type f -name 'train_features*.csv' | head -1)
31+
LABELS=$(find data/meta -type f -name 'train_labels*.csv' | head -1)
32+
TRANSCRIPTS=$(dirname "$(find data/transcripts -type f -name '*.csv' | head -1)")
33+
cd competitions/trace_the_ace
34+
python v95_objective_support_activation.py --features "../../$FEATURES" --labels "../../$LABELS" --transcripts "../../$TRANSCRIPTS" --out ../../v95_objective_support_activation.json
35+
- name: Upload result
36+
uses: actions/upload-artifact@v4
37+
with:
38+
name: trace-ace-v95-objective-support
39+
path: v95_objective_support_activation.json
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
#!/usr/bin/env python3
2+
"""V95: objective-support activation law for the V94 RELATED ability specialist.
3+
4+
Residual
5+
--------
6+
V94 showed that RELATED relative-ability evidence helps objective-cold validation
7+
but should receive zero weight in session-cold validation. The proposed missing
8+
applicability variable is epistemic support for the target objective.
9+
10+
Separator
11+
---------
12+
Take one deterministic objective-cold outer fold. For every held-out objective,
13+
reserve a deterministic support pool and a disjoint fixed evaluation set. Reveal
14+
nested amounts of labelled target-objective support (0, 1, 2, 4, 8, 16, 32+ per
15+
objective), refit V75 and the V94 RELATED expert, and score the *same* evaluation
16+
rows at every support level.
17+
18+
Prediction
19+
----------
20+
Optimal RELATED blend weight should be high at zero support and decline toward
21+
zero as target-objective support increases.
22+
23+
Cheap causal ablation
24+
---------------------
25+
At support 8 and 32+, shuffle only the newly revealed support labels before
26+
fitting the RELATED expert. Extra rows without valid target information should
27+
not reproduce a lawful support benefit.
28+
29+
No leaderboard score or hidden-test outcome is used anywhere in construction,
30+
fitting, weighting, or the promotion decision.
31+
"""
32+
from __future__ import annotations
33+
34+
import argparse
35+
import hashlib
36+
import json
37+
from pathlib import Path
38+
39+
import numpy as np
40+
from scipy.stats import spearmanr
41+
from sklearn.linear_model import LogisticRegression
42+
from sklearn.metrics import log_loss
43+
44+
from v71_mastery_events import load_transcript
45+
from v75_canonical_trajectory import load_training, SEED
46+
from v85_evidence_state import build_v75
47+
from v93_shift_robust_validation import folds_from_groups
48+
from v94_related_control import segmented_control, build_control
49+
50+
51+
LEVELS = [0, 1, 2, 4, 8, 16, "32+"]
52+
GRID = np.linspace(0.0, 0.6, 25)
53+
54+
55+
def stable_key(obj: str, response_id: str) -> str:
56+
return hashlib.sha256(f"V95|{SEED}|{obj}|{response_id}".encode()).hexdigest()
57+
58+
59+
def make_fixed_support_design(frame, val_idx, objective):
60+
"""Return disjoint nested support pools and one fixed evaluation index.
61+
62+
Up to half of each held-out objective (capped at 64 rows) is reserved as its
63+
support pool. The complement is scored at every support level, eliminating
64+
changing-evaluation-set confounding. Small objectives saturate naturally;
65+
the result records realised support counts for every level.
66+
"""
67+
val_idx = np.asarray(val_idx, dtype=int)
68+
response = (
69+
frame.response_id.astype(str).to_numpy()
70+
if "response_id" in frame
71+
else np.arange(len(frame)).astype(str)
72+
)
73+
pools = {}
74+
eval_parts = []
75+
for g in sorted(np.unique(objective[val_idx])):
76+
idx = val_idx[objective[val_idx] == g]
77+
idx = np.asarray(sorted(idx, key=lambda i: stable_key(str(g), response[i])), dtype=int)
78+
pool_n = min(64, len(idx) // 2)
79+
pools[str(g)] = idx[:pool_n]
80+
eval_parts.append(idx[pool_n:])
81+
eval_idx = np.concatenate(eval_parts) if eval_parts else np.array([], dtype=int)
82+
return pools, np.asarray(sorted(eval_idx), dtype=int)
83+
84+
85+
def support_indices(pools, level):
86+
out = []
87+
counts = []
88+
for g in sorted(pools):
89+
pool = pools[g]
90+
k = len(pool) if level == "32+" else min(int(level), len(pool))
91+
counts.append(k)
92+
if k:
93+
out.append(pool[:k])
94+
idx = np.concatenate(out) if out else np.array([], dtype=int)
95+
return np.asarray(sorted(idx), dtype=int), np.asarray(counts, dtype=int)
96+
97+
98+
def fit_predict(X, y, train_idx, eval_idx, y_train_override=None):
99+
yy = y[train_idx] if y_train_override is None else np.asarray(y_train_override, dtype=int)
100+
m = LogisticRegression(
101+
C=0.25,
102+
max_iter=300,
103+
solver="liblinear",
104+
random_state=SEED,
105+
).fit(X[train_idx], yy)
106+
return np.clip(m.predict_proba(X[eval_idx])[:, 1], 1e-5, 1 - 1e-5)
107+
108+
109+
def best_blend(y, p0, pa):
110+
curve = []
111+
for w in GRID:
112+
q = np.clip((1 - w) * p0 + w * pa, 1e-5, 1 - 1e-5)
113+
curve.append({"w": float(w), "ll": float(log_loss(y, q))})
114+
return min(curve, key=lambda z: z["ll"]), curve
115+
116+
117+
def realised_support_summary(counts):
118+
if not len(counts):
119+
return {"min": 0, "median": 0.0, "mean": 0.0, "max": 0, "objectives": 0}
120+
return {
121+
"min": int(np.min(counts)),
122+
"median": float(np.median(counts)),
123+
"mean": float(np.mean(counts)),
124+
"max": int(np.max(counts)),
125+
"objectives": int(len(counts)),
126+
}
127+
128+
129+
def run(a):
130+
f = load_training(a.features, a.labels).reset_index(drop=True)
131+
cache = {
132+
sid: load_transcript(a.transcripts / f"{sid}.csv")
133+
for sid in f.session_id.astype(str).unique()
134+
}
135+
136+
related_text, related_num = [], []
137+
for i, r in f.iterrows():
138+
d = cache[str(r.session_id)]
139+
t, z = segmented_control(d, str(r.learning_objective), "related")
140+
related_text.append(t)
141+
related_num.append(z)
142+
if (i + 1) % 2500 == 0:
143+
print("rows", i + 1)
144+
145+
X0 = build_v75(f, cache)
146+
Xr = build_control(related_text, related_num)
147+
y = f.target.to_numpy(int)
148+
obj = (
149+
f.learning_objective_id
150+
if "learning_objective_id" in f
151+
else f.learning_objective
152+
).astype(str).to_numpy()
153+
154+
# Cheapest sufficient causal world: the first deterministic GroupKFold
155+
# objective-cold split, held fixed for every support dose.
156+
base_train, heldout = folds_from_groups(obj)[0]
157+
pools, eval_idx = make_fixed_support_design(f, heldout, obj)
158+
if not len(eval_idx):
159+
raise RuntimeError("V95 fixed evaluation set is empty")
160+
161+
eval_y = y[eval_idx]
162+
results = []
163+
predictions = {}
164+
support_by_level = {}
165+
166+
for level in LEVELS:
167+
sup_idx, counts = support_indices(pools, level)
168+
train_idx = np.concatenate([np.asarray(base_train, dtype=int), sup_idx])
169+
p0 = fit_predict(X0, y, train_idx, eval_idx)
170+
pr = fit_predict(Xr, y, train_idx, eval_idx)
171+
b, curve = best_blend(eval_y, p0, pr)
172+
ll0 = float(log_loss(eval_y, p0))
173+
llr = float(log_loss(eval_y, pr))
174+
label = str(level)
175+
results.append({
176+
"support": label,
177+
"realised_support_per_objective": realised_support_summary(counts),
178+
"support_rows_total": int(len(sup_idx)),
179+
"eval_rows": int(len(eval_idx)),
180+
"v75": ll0,
181+
"related_ability": llr,
182+
"best": b,
183+
"gain_vs_v75": float(ll0 - b["ll"]),
184+
"blend_curve": curve,
185+
})
186+
predictions[label] = (p0, pr)
187+
support_by_level[label] = (sup_idx, counts)
188+
print("SUPPORT", label, "V75", ll0, "RELATED", llr, "BEST", b)
189+
190+
# Information-destruction ablation: same revealed rows and class marginal,
191+
# but support labels are deterministically shuffled. Only RELATED is refit;
192+
# this asks whether valid labelled support, rather than row count alone,
193+
# improves the specialist representation.
194+
ablations = {}
195+
rng = np.random.RandomState(SEED + 95)
196+
for level in (8, "32+"):
197+
label = str(level)
198+
sup_idx, _ = support_by_level[label]
199+
train_idx = np.concatenate([np.asarray(base_train, dtype=int), sup_idx])
200+
yy = y[train_idx].copy()
201+
nbase = len(base_train)
202+
if len(sup_idx) > 1:
203+
yy[nbase:] = yy[nbase:][rng.permutation(len(sup_idx))]
204+
pr_bad = fit_predict(Xr, y, train_idx, eval_idx, y_train_override=yy)
205+
normal_pr = predictions[label][1]
206+
ablations[label] = {
207+
"normal_related_ll": float(log_loss(eval_y, normal_pr)),
208+
"shuffled_support_related_ll": float(log_loss(eval_y, pr_bad)),
209+
"valid_information_gain": float(log_loss(eval_y, pr_bad) - log_loss(eval_y, normal_pr)),
210+
}
211+
print("ABLATION", label, ablations[label])
212+
213+
weights = np.asarray([r["best"]["w"] for r in results], dtype=float)
214+
# Use realised median support, with 32+ naturally reflecting the whole fixed pool.
215+
dose = np.asarray([r["realised_support_per_objective"]["median"] for r in results], dtype=float)
216+
rho = float(spearmanr(dose, weights).statistic) if len(np.unique(dose)) > 1 else 0.0
217+
near_monotone_steps = int(np.sum(np.diff(weights) <= 0.025 + 1e-12))
218+
possible_steps = len(weights) - 1
219+
delta = float(weights[0] - weights[-1])
220+
low_gain = float(results[0]["gain_vs_v75"])
221+
high_weight = float(weights[-1])
222+
223+
clean = (
224+
near_monotone_steps >= possible_steps - 1
225+
and rho <= -0.75
226+
and delta >= 0.15
227+
and low_gain >= 0.003
228+
and high_weight <= 0.15
229+
)
230+
partial = rho <= -0.50 and delta >= 0.10 and low_gain >= 0.002
231+
if clean:
232+
verdict = "PROMOTE_OBJECTIVE_SUPPORT_ACTIVATION"
233+
elif partial:
234+
verdict = "R5_REFINE_EFFECTIVE_SUPPORT"
235+
else:
236+
verdict = "SUPPRESS_OBJECTIVE_SUPPORT"
237+
238+
out = {
239+
"primary": "objective-support-activation-law",
240+
"design": {
241+
"outer_world": "first deterministic objective-cold GroupKFold split",
242+
"support_levels": [str(x) for x in LEVELS],
243+
"fixed_eval_rows": int(len(eval_idx)),
244+
"heldout_objectives": int(len(pools)),
245+
"support_pool_rule": "stable hash order; reserve up to half/objective capped at 64; score fixed complement",
246+
"note": "No leaderboard score or hidden-test outcome used in fitting, weighting, or decision.",
247+
},
248+
"support_response": results,
249+
"ablations": ablations,
250+
"decision": {
251+
"spearman_support_vs_weight": rho,
252+
"near_monotone_steps": near_monotone_steps,
253+
"possible_steps": possible_steps,
254+
"weight_drop_zero_to_32plus": delta,
255+
"zero_support_gain_vs_v75": low_gain,
256+
"high_support_weight": high_weight,
257+
"verdict": verdict,
258+
"precommit": {
259+
"promote": "near-monotone (<=1 tolerance step), rho<=-0.75, weight drop>=0.15, zero-support gain>=0.003, 32+ weight<=0.15",
260+
"refine": "rho<=-0.50, weight drop>=0.10, zero-support gain>=0.002",
261+
"otherwise": "suppress raw objective support and seek another observable",
262+
},
263+
},
264+
}
265+
Path(a.out).write_text(json.dumps(out, indent=2))
266+
print(json.dumps(out, indent=2))
267+
268+
269+
if __name__ == "__main__":
270+
p = argparse.ArgumentParser()
271+
p.add_argument("--features", type=Path, required=True)
272+
p.add_argument("--labels", type=Path, required=True)
273+
p.add_argument("--transcripts", type=Path, required=True)
274+
p.add_argument("--out", default="v95_objective_support_activation.json")
275+
run(p.parse_args())

0 commit comments

Comments
 (0)