harness-everything 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +1 -0
  4. package/CHANGELOG.md +35 -0
  5. package/PRIVACY.md +9 -0
  6. package/benchmarks/fixtures/system-one-intent-holdout-draft.json +2228 -0
  7. package/benchmarks/fixtures/system-one-intent-holdout.json +16509 -0
  8. package/benchmarks/fixtures/system-one-intent-reviews.json +2723 -0
  9. package/benchmarks/results/system-one/intent-ngram-v1-2026-09-24/README.md +64 -0
  10. package/benchmarks/results/system-one/intent-ngram-v1-2026-09-24/report.json +15177 -0
  11. package/benchmarks/results/system-one/labeler-holdout-check-intent-2026-09-24/report.json +2976 -0
  12. package/benchmarks/results/system-one/labeler-holdout-check-intent-scores-haiku-2026-09-24/report-005-steps.json +6125 -0
  13. package/benchmarks/results/system-one/labeler-holdout-check-intent-scores-haiku-2026-09-24/report-haiku-sonnet-escalation.json +6344 -0
  14. package/benchmarks/results/system-one/labeler-holdout-check-intent-scores-haiku-2026-09-24/report-scaled-margin-escalation.json +6206 -0
  15. package/benchmarks/results/system-one/labeler-holdout-check-intent-scores-haiku-2026-09-24/report.json +6103 -0
  16. package/benchmarks/results/system-one/laya-ft-2026-09-25/README.md +83 -0
  17. package/benchmarks/results/system-one/laya-ft-2026-09-25/report.json +71 -0
  18. package/benchmarks/results/system-one/laya-ft-2026-09-25/shadow-report.json +44 -0
  19. package/benchmarks/results/system-one/laya-zs-2026-09-25/README.md +68 -0
  20. package/benchmarks/results/system-one/laya-zs-2026-09-25/report.json +173 -0
  21. package/ci/mechanism-34-observations.test.js +550 -0
  22. package/ci/mechanism-system-one-collect.test.js +16 -0
  23. package/ci/mechanism-system-one-intent.test.js +406 -0
  24. package/ci/mechanism-system-one-laya.test.js +45 -0
  25. package/ci/mechanism-system-one-relevance.test.js +112 -0
  26. package/ci/mechanism-system-one-resident.test.js +16 -0
  27. package/ci/system-one-laya-calibrate-test.py +65 -0
  28. package/ci/system-one-laya-export-test.py +155 -0
  29. package/ci/system-one-laya-label-test.py +201 -0
  30. package/ci/system-one-laya-perintent-test.py +60 -0
  31. package/ci/system-one-laya-provider-test.py +115 -0
  32. package/ci/system-one-laya-train-test.py +93 -0
  33. package/docs/routing.md +25 -0
  34. package/docs/system-one-intent-gates.md +102 -0
  35. package/docs/system-one-intent.md +173 -0
  36. package/docs/system-one-laya-training.md +38 -0
  37. package/docs/system-one-observations.md +206 -0
  38. package/docs/system-one-routing.md +25 -10
  39. package/docs/system-one-suggestion-gates.md +292 -0
  40. package/docs/system-one-training.md +56 -6
  41. package/harness-everything/SKILL.md +1 -1
  42. package/harness-everything/scripts/kernel-router.js +10 -0
  43. package/harness-everything/scripts/system-one/catalogs.js +35 -0
  44. package/harness-everything/scripts/system-one/contract.js +45 -1
  45. package/harness-everything/scripts/system-one/evaluate.js +115 -19
  46. package/harness-everything/scripts/system-one/intent.js +22 -0
  47. package/harness-everything/scripts/system-one/laya_adapter.py +141 -0
  48. package/harness-everything/scripts/system-one/provider.js +10 -3
  49. package/harness-everything/scripts/system-one/resident.js +8 -3
  50. package/harness-everything/scripts/system-one/router.js +1 -1
  51. package/hooks/hooks.json +40 -0
  52. package/hooks/scripts/codex-user-prompt.js +39 -0
  53. package/hooks/scripts/lib/observations.js +581 -0
  54. package/hooks/scripts/observation-hook.js +25 -0
  55. package/opencode-plugin/plugin.json +1 -1
  56. package/package.json +1 -1
  57. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  58. package/plugins/harness-everything/hooks/hooks.json +35 -2
  59. package/plugins/harness-everything/hooks/scripts/codex-user-prompt.js +39 -0
  60. package/plugins/harness-everything/hooks/scripts/lib/observations.js +581 -0
  61. package/plugins/harness-everything/hooks/scripts/observation-hook.js +25 -0
  62. package/plugins/harness-everything/plugin.json +1 -1
  63. package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
  64. package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router.js +10 -0
  65. package/plugins/harness-everything/skills/harness-everything/scripts/system-one/catalogs.js +35 -0
  66. package/plugins/harness-everything/skills/harness-everything/scripts/system-one/contract.js +45 -1
  67. package/plugins/harness-everything/skills/harness-everything/scripts/system-one/evaluate.js +115 -19
  68. package/plugins/harness-everything/skills/harness-everything/scripts/system-one/intent.js +22 -0
  69. package/plugins/harness-everything/skills/harness-everything/scripts/system-one/laya_adapter.py +141 -0
  70. package/plugins/harness-everything/skills/harness-everything/scripts/system-one/provider.js +10 -3
  71. package/plugins/harness-everything/skills/harness-everything/scripts/system-one/resident.js +8 -3
  72. package/plugins/harness-everything/skills/harness-everything/scripts/system-one/router.js +1 -1
  73. package/sbom.cdx.json +7 -7
  74. package/scripts/evaluate-system-one.js +17 -5
  75. package/scripts/system-one-calibrate-laya.py +165 -0
  76. package/scripts/system-one-calibrate.js +42 -13
  77. package/scripts/system-one-collect.js +7 -3
  78. package/scripts/system-one-compare-laya.py +156 -0
  79. package/scripts/system-one-corpus.js +22 -15
  80. package/scripts/system-one-eval-laya.js +112 -0
  81. package/scripts/system-one-export-laya.py +197 -0
  82. package/scripts/system-one-label-laya.py +243 -0
  83. package/scripts/system-one-label.js +163 -37
  84. package/scripts/system-one-observations-export.js +106 -0
  85. package/scripts/system-one-observations-review.js +86 -0
  86. package/scripts/system-one-perintent-laya.py +134 -0
  87. package/scripts/system-one-train-laya.py +344 -0
  88. package/scripts/system-one-train-ngram.py +66 -14
@@ -0,0 +1,344 @@
1
+ #!/usr/bin/env python3
2
+ """Single-device RLCD fine-tuning for Laya (#255 Phase 3).
3
+
4
+ Faithful single-process port of the upstream Kaggle DDP recipe
5
+ (notebooks/laya_finetune_typed_decisions_2xT4_kaggle.ipynb, train_ddp.py):
6
+ same GRPO + soft cross-entropy objective, same calibration holdout seed,
7
+ same temperature fit, same artifact layout. Differences: no NCCL/DDP;
8
+ cuda uses fp16+scaler, mps/cpu train fp32 without a scaler.
9
+
10
+ Input is the reviewable items jsonl from system-one-export-laya.py.
11
+ Output is a loadable checkpoint dir (model.safetensors + encoder/ +
12
+ tokenizer/ + rl_agent_config.json). Heavy deps import lazily so contract
13
+ tests run on stdlib.
14
+ """
15
+ import argparse
16
+ import json
17
+ import random
18
+ import sys
19
+ import time
20
+ from pathlib import Path
21
+
22
+ try:
23
+ import torch
24
+ _TORCH = True
25
+ except ImportError:
26
+ torch = None
27
+ _TORCH = False
28
+
29
+ CALIB_SEED = 20260922
30
+ CALIB_MAX = 400
31
+ INTENTS = ['explain', 'discuss', 'git', 'fix', 'edit', 'feature', 'refactor',
32
+ 'review', 'test', 'docs', 'plan', 'investigate']
33
+
34
+
35
+ def need_torch():
36
+ if not _TORCH:
37
+ raise RuntimeError('torch is required for training')
38
+
39
+
40
+ def intent_weights(intents, table):
41
+ """Per-item loss multipliers by training intent; unlisted intents weigh 1."""
42
+ import math # noqa: PLC0415
43
+ table = dict(table or {})
44
+ for intent, weight in table.items():
45
+ if intent not in INTENTS or not isinstance(weight, (int, float)) \
46
+ or not math.isfinite(weight) or weight <= 0:
47
+ raise ValueError(f'intent-weights: bad entry {intent!r}:{weight!r}')
48
+ return [float(table.get(intent, 1.0)) for intent in intents]
49
+
50
+
51
+ def calib_split(items, seed=CALIB_SEED, frac=0.1):
52
+ """Deterministic calibration split, grouped by prompt when promptId exists.
53
+
54
+ Calibration keeps one copy per promptId+intent identity. Oversampled copies
55
+ survive only in training groups, preventing exact-duplicate leakage and
56
+ calibration reweighting. Callers without promptId retain per-item behavior.
57
+ """
58
+ groups = {}
59
+ group_order = []
60
+ for index, item in enumerate(items):
61
+ prompt_id = item.get('promptId')
62
+ key = ('prompt', str(prompt_id)) if prompt_id is not None else ('item', index)
63
+ if key not in groups:
64
+ groups[key] = []
65
+ group_order.append(key)
66
+ groups[key].append(item)
67
+
68
+ def calib_rows(group):
69
+ if not group or group[0].get('promptId') is None:
70
+ return list(group)
71
+ seen, rows = set(), []
72
+ for item in group:
73
+ identity = (item.get('promptId'), item.get('intent'))
74
+ if identity in seen:
75
+ continue
76
+ seen.add(identity)
77
+ rows.append(item)
78
+ return rows
79
+
80
+ base_groups = {key: calib_rows(group) for key, group in groups.items()}
81
+ base_total = sum(len(group) for group in base_groups.values())
82
+ if base_total == 0:
83
+ return [], []
84
+ n_calib = max(1, min(CALIB_MAX, base_total // 10 if frac == 0.1 else int(base_total * frac)))
85
+
86
+ shuffled = list(group_order)
87
+ random.Random(seed).shuffle(shuffled)
88
+ calib_groups, calib_count = set(), 0
89
+ for key in shuffled:
90
+ size = len(base_groups[key])
91
+ if calib_count == 0 and size > n_calib:
92
+ calib_groups.add(key)
93
+ break
94
+ if calib_count + size <= n_calib:
95
+ calib_groups.add(key)
96
+ calib_count += size
97
+ if calib_count == n_calib:
98
+ break
99
+
100
+ train, calib = [], []
101
+ for key in group_order:
102
+ if key in calib_groups:
103
+ calib.extend(base_groups[key])
104
+ else:
105
+ train.extend(groups[key])
106
+ return train, calib
107
+
108
+
109
+ def collate(items, pad_id):
110
+ """Batch upstream-format items into padded tensors."""
111
+ need_torch()
112
+ n, max_len = len(items), max(len(it['ids']) for it in items)
113
+ kmax = max(len(it['markers']) for it in items)
114
+ ids = torch.full((n, max_len), pad_id, dtype=torch.long)
115
+ att = torch.zeros((n, max_len), dtype=torch.long)
116
+ mpos = torch.zeros((n, kmax), dtype=torch.long)
117
+ mmask = torch.zeros((n, kmax), dtype=torch.bool)
118
+ target = torch.zeros((n, kmax), dtype=torch.float32)
119
+ for i, it in enumerate(items):
120
+ ids[i, :len(it['ids'])] = torch.tensor(it['ids'])
121
+ att[i, :len(it['ids'])] = 1
122
+ k = len(it['markers'])
123
+ mpos[i, :k] = torch.tensor(it['markers'])
124
+ mmask[i, :k] = True
125
+ target[i, :len(it['target'])] = torch.tensor(it['target'], dtype=torch.float32)
126
+ return {'input_ids': ids, 'attention_mask': att, 'marker_pos': mpos, 'marker_mask': mmask,
127
+ 'target': target,
128
+ 'qtype': torch.tensor([it['qtype'] for it in items]),
129
+ 'label': torch.tensor([it['label'] for it in items])}
130
+
131
+
132
+ def fit_one_temp(sel):
133
+ """Fit one temperature per question type with LBFGS (upstream recipe)."""
134
+ need_torch()
135
+ if len(sel) < 10:
136
+ return 1.0
137
+ kmax = max(len(z) for z, _ in sel)
138
+ Z = torch.full((len(sel), kmax), -1e4)
139
+ T = torch.zeros((len(sel), kmax))
140
+ for i, (z, t) in enumerate(sel):
141
+ Z[i, :len(z)] = torch.tensor(z)
142
+ T[i, :len(t)] = torch.tensor(t, dtype=torch.float32)
143
+ log_t = torch.zeros(1, requires_grad=True)
144
+ opt = torch.optim.LBFGS([log_t], lr=0.1, max_iter=100)
145
+
146
+ def closure():
147
+ opt.zero_grad()
148
+ loss = -(T * torch.log_softmax(Z / log_t.exp(), -1)).sum(-1).mean()
149
+ loss.backward()
150
+ return loss
151
+
152
+ opt.step(closure)
153
+ return float(torch.clamp(log_t.exp(), 0.1, 10.0).item())
154
+
155
+
156
+ def pick_device(name):
157
+ need_torch()
158
+ if name == 'auto':
159
+ if torch.cuda.is_available():
160
+ return 'cuda'
161
+ if hasattr(torch, 'backends') and torch.backends.mps.is_available():
162
+ return 'mps'
163
+ return 'cpu'
164
+ return name
165
+
166
+
167
+ def train(args):
168
+ need_torch()
169
+ from safetensors.torch import load_file, save_file # noqa: PLC0415
170
+ from transformers import AutoTokenizer # noqa: PLC0415
171
+ from laya.agent import _fix_tokenizer_config # noqa: PLC0415
172
+ from laya.common import build_model, proper_reward # noqa: PLC0415
173
+
174
+ device = pick_device(args.device)
175
+ use_amp = device == 'cuda'
176
+ out_dir = Path(args.out)
177
+ out_dir.mkdir(parents=True, exist_ok=True)
178
+
179
+ items = [json.loads(line) for line in Path(args.items).read_text(encoding='utf-8').splitlines() if line.strip()]
180
+ train_items, calib_items = calib_split(items, seed=args.calib_seed, frac=args.calib_frac)
181
+ weights_table = json.loads(args.intent_weights)
182
+ intent_weights([], weights_table) # validate early, before GPU time
183
+ print(json.dumps({'trainItems': len(train_items), 'calibItems': len(calib_items), 'device': device,
184
+ 'intentWeights': weights_table}), flush=True)
185
+
186
+ from huggingface_hub import snapshot_download # noqa: PLC0415
187
+ model_dir = Path(snapshot_download(args.checkpoint))
188
+ base = model_dir / args.subfolder if args.subfolder else model_dir
189
+ _fix_tokenizer_config(str(base))
190
+ with open(base / 'rl_agent_config.json', encoding='utf-8') as fh:
191
+ cfg = json.load(fh)
192
+ cfg['max_tokens_per_batch'] = 4096
193
+ cfg['max_len'] = 1024
194
+ cfg['head_max_len'] = 256
195
+ tok = AutoTokenizer.from_pretrained(str(base / 'tokenizer'))
196
+ model = build_model(cfg, encoder_dir=str(base / 'encoder'))
197
+ model.load_state_dict(load_file(str(base / 'model.safetensors')), strict=True)
198
+ model.encoder.gradient_checkpointing_enable(gradient_checkpointing_kwargs={'use_reentrant': False})
199
+ model.head_checkpointing = True
200
+ model.to(device)
201
+ model.train()
202
+
203
+ enc_params = [p for n, p in model.named_parameters() if 'encoder.' in n]
204
+ head_params = [p for n, p in model.named_parameters() if 'encoder.' not in n]
205
+ optimizer = torch.optim.AdamW([
206
+ {'params': enc_params, 'lr': args.lr_encoder},
207
+ {'params': head_params, 'lr': args.lr_head},
208
+ ], weight_decay=0.01)
209
+ updates_per_epoch = max(1, len(train_items) // (args.micro_batch * args.grad_accum))
210
+ scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
211
+ optimizer, T_max=max(1, updates_per_epoch * args.epochs), eta_min=1e-6)
212
+ scaler = torch.amp.GradScaler('cuda', enabled=use_amp)
213
+
214
+ t0 = time.time()
215
+ for epoch in range(args.epochs):
216
+ rnd = random.Random(42 + epoch)
217
+ rnd.shuffle(train_items)
218
+ sigma = args.sigma_start + (args.sigma_end - args.sigma_start) * (epoch / max(1, args.epochs - 1))
219
+ epoch_loss, n_batches = 0.0, 0
220
+ optimizer.zero_grad(set_to_none=True)
221
+ accum = 0
222
+ for b in range(0, len(train_items), args.micro_batch):
223
+ chunk = train_items[b:b + args.micro_batch]
224
+ batch = collate(chunk, tok.pad_token_id)
225
+ ctx = torch.amp.autocast('cuda', dtype=torch.float16) if use_amp else torch.amp.autocast('cpu', enabled=False)
226
+ with ctx:
227
+ logits, act = model(batch['input_ids'].to(device), batch['attention_mask'].to(device),
228
+ batch['marker_pos'].to(device), batch['marker_mask'].to(device),
229
+ batch['qtype'].to(device))
230
+ logits = logits.float()
231
+ mask = batch['marker_mask'].to(device)
232
+ k = mask.sum(-1, keepdim=True).float()
233
+ target = batch['target'].to(device)
234
+ eps = torch.randn((args.group_size,) + logits.shape, device=device) * sigma * mask
235
+ eps = (eps - eps.sum(-1, keepdim=True) / k) * mask
236
+ z = logits.detach().unsqueeze(0) + eps
237
+ q = torch.softmax(z.masked_fill(~mask, -1e4), -1)
238
+ with torch.no_grad():
239
+ r = proper_reward(q, target.unsqueeze(0), batch['qtype'].to(device), mask,
240
+ w_sph=0.75, w_rps=1.0)
241
+ adv = r - r.mean(0, keepdim=True)
242
+ adv = adv / (adv.std() + 1e-6)
243
+ logp = -(((z - logits.unsqueeze(0)) ** 2) * mask).sum(-1) / (2 * sigma ** 2)
244
+ sample_w = torch.tensor(
245
+ intent_weights([it.get('intent') for it in chunk], weights_table),
246
+ dtype=torch.float32, device=device)
247
+ loss_rl = (-(adv * logp).mean(0) * sample_w).mean()
248
+ loss_ce = ((-(target * torch.log_softmax(logits.masked_fill(~mask, -1e4), -1)).sum(-1)) * sample_w).mean()
249
+ loss = (loss_rl + loss_ce) / args.grad_accum + 0.0 * act.sum()
250
+ scaler.scale(loss).backward()
251
+ accum += 1
252
+ if accum % args.grad_accum == 0 or (b + args.micro_batch) >= len(train_items):
253
+ scaler.unscale_(optimizer)
254
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
255
+ scaler.step(optimizer)
256
+ scaler.update()
257
+ scheduler.step()
258
+ optimizer.zero_grad(set_to_none=True)
259
+ epoch_loss += loss.item() * args.grad_accum
260
+ n_batches += 1
261
+ if n_batches % 50 == 0:
262
+ print(json.dumps({'epoch': epoch + 1, 'step': n_batches,
263
+ 'loss': round(loss.item() * args.grad_accum, 4),
264
+ 'reward': round(r.mean().item(), 3)}), flush=True)
265
+ ckpt = out_dir / 'checkpoint_latest'
266
+ ckpt.mkdir(exist_ok=True)
267
+ ckpt_sd = {k: v.half().contiguous().cpu() for k, v in model.state_dict().items()}
268
+ save_file(ckpt_sd, str(ckpt / 'model.safetensors'))
269
+ model.encoder.config.save_pretrained(str(ckpt / 'encoder'))
270
+ tok.save_pretrained(str(ckpt / 'tokenizer'))
271
+ (ckpt / 'checkpoint_meta.json').write_text(json.dumps(
272
+ {'epoch': epoch + 1, 'totalEpochs': args.epochs,
273
+ 'avgLoss': epoch_loss / max(1, n_batches)}), encoding='utf-8')
274
+ print(json.dumps({'epochDone': epoch + 1, 'avgLoss': round(epoch_loss / max(1, n_batches), 4),
275
+ 'elapsedS': round(time.time() - t0, 1)}), flush=True)
276
+ if device in ('cuda', 'mps'):
277
+ getattr(torch, device).empty_cache()
278
+
279
+ print(json.dumps({'fittingTemperatures': True}), flush=True)
280
+ del optimizer, scaler, scheduler
281
+ if device == 'cuda':
282
+ torch.cuda.empty_cache()
283
+ model.eval()
284
+ calib_preds = []
285
+ with torch.no_grad():
286
+ for c in range(0, len(calib_items), 16):
287
+ chunk = calib_items[c:c + 16]
288
+ cb = collate(chunk, tok.pad_token_id)
289
+ ctx = torch.amp.autocast('cuda', dtype=torch.float16) if use_amp else torch.amp.autocast('cpu', enabled=False)
290
+ with ctx:
291
+ l_sub, _ = model(cb['input_ids'].to(device), cb['attention_mask'].to(device),
292
+ cb['marker_pos'].to(device), cb['marker_mask'].to(device),
293
+ cb['qtype'].to(device))
294
+ l_np = l_sub.float().cpu().numpy()
295
+ for r, it in enumerate(chunk):
296
+ k = len(it['markers'])
297
+ calib_preds.append((it['qtype'], l_np[r, :k], it['target']))
298
+ fitted = [1.2, 1.2, 1.2]
299
+ try:
300
+ for qt in range(3):
301
+ sel = [(z, t) for q_type, z, t in calib_preds if q_type == qt]
302
+ if sel:
303
+ fitted[qt] = fit_one_temp(sel)
304
+ except Exception as exc: # noqa: BLE001
305
+ print(json.dumps({'temperatureFallback': str(exc)}), flush=True)
306
+ sd = {k: v.half().contiguous().cpu() for k, v in model.state_dict().items()}
307
+ save_file(sd, str(out_dir / 'model.safetensors'))
308
+ model.encoder.config.save_pretrained(str(out_dir / 'encoder'))
309
+ tok.save_pretrained(str(out_dir / 'tokenizer'))
310
+ cfg['fine_tuned'] = True
311
+ cfg['model_name'] = args.model_name
312
+ cfg['temperature'] = fitted
313
+ cfg.pop('temperature_by_options', None)
314
+ (out_dir / 'rl_agent_config.json').write_text(json.dumps(cfg, indent=2), encoding='utf-8')
315
+ print(json.dumps({'done': True, 'out': str(out_dir), 'temperatures': [round(t, 3) for t in fitted],
316
+ 'elapsedS': round(time.time() - t0, 1)}), flush=True)
317
+ return 0
318
+
319
+
320
+ def main(argv=None):
321
+ parser = argparse.ArgumentParser(description=__doc__)
322
+ parser.add_argument('--items', required=True, help='items jsonl from system-one-export-laya.py')
323
+ parser.add_argument('--out', required=True, help='checkpoint output dir')
324
+ parser.add_argument('--checkpoint', default='convaiinnovations/laya')
325
+ parser.add_argument('--subfolder', default='multilingual')
326
+ parser.add_argument('--model-name', default='laya-harness-intent-s1')
327
+ parser.add_argument('--device', default='auto', choices=['auto', 'cuda', 'mps', 'cpu'])
328
+ parser.add_argument('--epochs', type=int, default=2)
329
+ parser.add_argument('--micro-batch', type=int, default=2)
330
+ parser.add_argument('--grad-accum', type=int, default=8)
331
+ parser.add_argument('--group-size', type=int, default=4)
332
+ parser.add_argument('--lr-encoder', type=float, default=2.5e-5)
333
+ parser.add_argument('--lr-head', type=float, default=1e-4)
334
+ parser.add_argument('--sigma-start', type=float, default=0.4)
335
+ parser.add_argument('--sigma-end', type=float, default=0.1)
336
+ parser.add_argument('--calib-seed', type=int, default=CALIB_SEED)
337
+ parser.add_argument('--calib-frac', type=float, default=0.1)
338
+ parser.add_argument('--intent-weights', default='{}', help='JSON {intent: multiplier} for loss weighting')
339
+ args = parser.parse_args(argv)
340
+ return train(args)
341
+
342
+
343
+ if __name__ == '__main__':
344
+ sys.exit(main())
@@ -15,6 +15,7 @@ import sys
15
15
  sys.dont_write_bytecode = True
16
16
  REPO = Path(__file__).resolve().parents[1]
17
17
  DEFAULT_HOLDOUT = REPO / 'benchmarks' / 'fixtures' / 'system-one-holdout.json'
18
+ INTENT_HOLDOUT = REPO / 'benchmarks' / 'fixtures' / 'system-one-intent-holdout.json'
18
19
 
19
20
 
20
21
  def fnv1a32(text):
@@ -56,12 +57,27 @@ def train(args):
56
57
 
57
58
  torch.manual_seed(args.seed)
58
59
  data = Path(args.data_dir)
59
- corpus = json.loads(Path(args.holdout).read_text(encoding='utf-8'))
60
+ intent = args.task == 'intent'
61
+ scored = args.target == 'scores'
62
+ holdout = args.holdout or (INTENT_HOLDOUT if intent else DEFAULT_HOLDOUT)
63
+ corpus = json.loads(Path(holdout).read_text(encoding='utf-8'))
60
64
  catalog = [o['id'] for o in corpus['cases'][0]['request']['options']]
61
- gold = {r['id']: r['gold'] for r in load_jsonl(data / 'labels.jsonl')}
62
- overrides = data / 'owner-overrides.jsonl'
65
+ labels_file = data / ('labels-intent-scores.jsonl' if scored else 'labels-intent.jsonl' if intent else 'labels.jsonl')
66
+ if not labels_file.exists():
67
+ sys.exit(f'missing {labels_file.name}')
68
+ overrides = data / ('owner-overrides-intent.jsonl' if intent else 'owner-overrides.jsonl')
69
+ labeled = load_jsonl(labels_file)
70
+ gold = {r['id']: r['gold'] for r in labeled}
71
+ secondary = {r['id']: r.get('secondary', []) for r in labeled} if intent else {}
72
+ relevance = {r['id']: r['scores'] for r in labeled} if scored else {}
63
73
  if overrides.exists():
64
- gold.update({r['id']: r['gold'] for r in load_jsonl(overrides)})
74
+ for r in load_jsonl(overrides):
75
+ gold[r['id']] = r['gold']
76
+ if intent:
77
+ secondary[r['id']] = r.get('secondary', [])
78
+ if scored:
79
+ # An override without scores counts as 0.6 for its primary and 0.4 for its secondary intents.
80
+ relevance[r['id']] = r.get('scores') or {s: 0.6 if s == r['gold'] else 0.4 for s in [r['gold'], *r.get('secondary', [])] if s is not None}
65
81
  excluded = data / 'owner-excluded.jsonl'
66
82
  skip = {r['id'] for r in load_jsonl(excluded)} if excluded.exists() else set()
67
83
  rows = [p for p in load_jsonl(data / 'prompts.jsonl') if p['id'] in gold and p['id'] not in skip]
@@ -77,34 +93,65 @@ def train(args):
77
93
 
78
94
  x_train, x_val = matrix(split['train']), matrix(split['validation'])
79
95
  y_train = torch.tensor([index(gold[r['id']]) for r in split['train']])
80
- counts = torch.bincount(y_train, minlength=len(catalog)).float()
81
- inverse = torch.where(counts > 0, counts.sum() / (len(catalog) * counts.clamp_min(1)), torch.zeros_like(counts))
96
+ # Intent targets are soft: the primary alone gets 1.0; with secondaries it gets 0.6 and they share 0.4.
97
+ target = y_train
98
+ if scored:
99
+ # Scored intent labels: each intent is its own binary target with its relevance score as the soft value.
100
+ target = torch.tensor([[(0.6 if gold[r['id']] is None else 0.0) if c == 'unclassified' else float(relevance[r['id']].get(c, 0.0))
101
+ for c in catalog] for r in split['train']], dtype=torch.float32)
102
+ elif intent:
103
+ rows_t = []
104
+ for r in split['train']:
105
+ t = [0.0] * len(catalog)
106
+ sec = [s for s in secondary.get(r['id'], []) if s in catalog]
107
+ if gold[r['id']] is None or not sec:
108
+ t[index(gold[r['id']])] = 1.0
109
+ else:
110
+ t[index(gold[r['id']])] = 0.6
111
+ for s in sec:
112
+ t[catalog.index(s)] += 0.4 / len(sec)
113
+ rows_t.append(t)
114
+ target = torch.tensor(rows_t, dtype=torch.float32)
115
+ # Class mass counts the soft targets, so an intent seen only as secondary still carries weight.
116
+ counts = target.sum(0) if intent or scored else torch.bincount(y_train, minlength=len(catalog)).float()
117
+ inverse = torch.where(counts > 0, counts.sum() / (len(catalog) * counts.clamp_min(1e-6)), torch.zeros_like(counts))
82
118
  weight = {'none': None, 'sqrt': inverse.sqrt(), 'full': inverse}[args.balance]
83
119
  W = torch.zeros(args.dim, len(catalog), requires_grad=True)
84
120
  bias = torch.zeros(len(catalog), requires_grad=True)
85
121
  optimizer = torch.optim.Adam([W, bias], lr=args.lr)
86
122
  for _ in range(args.epochs):
87
123
  optimizer.zero_grad()
88
- loss = torch.nn.functional.cross_entropy(torch.sparse.mm(x_train, W) + bias, y_train, weight=weight) + args.weight_decay * (W ** 2).sum()
124
+ logits = torch.sparse.mm(x_train, W) + bias
125
+ if scored:
126
+ per = torch.nn.functional.binary_cross_entropy_with_logits(logits, target, reduction='none')
127
+ fit = (per * (weight if weight is not None else 1.0)).mean()
128
+ else:
129
+ fit = torch.nn.functional.cross_entropy(logits, target, weight=weight)
130
+ loss = fit + args.weight_decay * (W ** 2).sum()
89
131
  loss.backward()
90
132
  optimizer.step()
91
133
  with torch.no_grad():
134
+ # The provider returns the softmax over the logits for either loss (docs/system-one-training.md).
92
135
  probs = torch.softmax(torch.sparse.mm(x_val, W) + bias, 1)
93
136
  out = Path(args.out)
94
137
  out.mkdir(parents=True, exist_ok=True)
95
138
  weights = W.detach().contiguous().view(-1).tolist()
96
139
  (out / 'harness-routing-v1.bin').write_bytes(struct.pack(f'<{len(weights)}f', *weights))
97
- datasets = {name: sha256_file(data / name) for name in ('prompts.jsonl', 'labels.jsonl')}
140
+ datasets = {name: sha256_file(data / name) for name in ('prompts.jsonl', labels_file.name)}
98
141
  if overrides.exists():
99
- datasets['owner-overrides.jsonl'] = sha256_file(overrides)
142
+ datasets[overrides.name] = sha256_file(overrides)
100
143
  sidecar = {'format': 'harness-ngram', 'formatVersion': 1, 'dim': args.dim, 'nmax': args.nmax, 'hash': 'fnv1a32', 'catalog': catalog,
101
144
  'bias': [float(v) for v in bias.detach().tolist()],
102
- 'metadata': {'domain': 'harness-routing-v1', 'balance': args.balance, 'weightDecay': args.weight_decay, 'epochs': args.epochs,
103
- 'seed': args.seed, 'datasets': datasets, 'counts': {k: len(v) for k, v in split.items()}}}
145
+ 'metadata': {'domain': 'harness-routing-v1', 'task': args.task, 'balance': args.balance, 'weightDecay': args.weight_decay, 'epochs': args.epochs,
146
+ 'seed': args.seed, 'datasets': datasets, 'counts': {k: len(v) for k, v in split.items()},
147
+ **({'target': 'scores', 'loss': 'bce'} if scored else {})}}
104
148
  (out / 'harness-routing-v1.json').write_text(json.dumps(sidecar), encoding='utf-8')
105
149
  with (out / 'val-scores.jsonl').open('w', encoding='utf-8') as fh:
106
150
  for r, p in zip(split['validation'], probs.tolist()):
107
- fh.write(json.dumps({'id': r['id'], 'gold': gold[r['id']], 'probs': p}) + '\n')
151
+ row = {'id': r['id'], 'gold': gold[r['id']], 'probs': p}
152
+ if intent:
153
+ row['secondary'] = secondary.get(r['id'], [])
154
+ fh.write(json.dumps(row) + '\n')
108
155
  y_val = torch.tensor([index(gold[r['id']]) for r in split['validation']])
109
156
  accuracy = (probs.argmax(1) == y_val).float().mean().item() if len(y_val) else 0.0
110
157
  print(json.dumps({'counts': sidecar['metadata']['counts'], 'validationAccuracy': accuracy}))
@@ -114,7 +161,9 @@ def main(argv=None):
114
161
  parser = argparse.ArgumentParser(description=__doc__)
115
162
  parser.add_argument('--data-dir', required=True)
116
163
  parser.add_argument('--out', required=True)
117
- parser.add_argument('--holdout', default=str(DEFAULT_HOLDOUT), help='source of the fixed catalog only; holdout rows are never trained on')
164
+ parser.add_argument('--task', choices=['tier', 'intent'], default='tier')
165
+ parser.add_argument('--target', choices=['label', 'scores'], default='label', help='scores: per-intent relevance targets from labels-intent-scores.jsonl (intent only)')
166
+ parser.add_argument('--holdout', default=None, help='source of the fixed catalog only (defaults to the task holdout); holdout rows are never trained on')
118
167
  parser.add_argument('--dim', type=int, default=1 << 16)
119
168
  parser.add_argument('--nmax', type=int, default=3)
120
169
  parser.add_argument('--balance', choices=['none', 'sqrt', 'full'], default='sqrt')
@@ -122,7 +171,10 @@ def main(argv=None):
122
171
  parser.add_argument('--epochs', type=int, default=60)
123
172
  parser.add_argument('--lr', type=float, default=0.01)
124
173
  parser.add_argument('--seed', type=int, default=7)
125
- train(parser.parse_args(argv))
174
+ args = parser.parse_args(argv)
175
+ if args.target == 'scores' and args.task != 'intent':
176
+ parser.error('--target scores needs --task intent')
177
+ train(args)
126
178
 
127
179
 
128
180
  if __name__ == '__main__':