eq-layer 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {eq_layer-0.1.0 → eq_layer-0.2.0}/PKG-INFO +35 -1
- {eq_layer-0.1.0 → eq_layer-0.2.0}/README.md +34 -0
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/__init__.py +1 -1
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/ab_eval.py +24 -4
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/cli.py +1 -1
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/response_experiment.py +227 -7
- {eq_layer-0.1.0 → eq_layer-0.2.0}/pyproject.toml +1 -1
- {eq_layer-0.1.0 → eq_layer-0.2.0}/.gitignore +0 -0
- {eq_layer-0.1.0 → eq_layer-0.2.0}/LICENSE +0 -0
- {eq_layer-0.1.0 → eq_layer-0.2.0}/THIRD_PARTY_DATA.md +0 -0
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/actions.py +0 -0
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/affect.py +0 -0
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/auditor.py +0 -0
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/coarse_discourse.py +0 -0
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/data/intent_train.jsonl +0 -0
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/dbdc3.py +0 -0
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/dialogbank_iso.py +0 -0
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/dialogue_reference.py +0 -0
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/intent.py +0 -0
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/intent_belief.py +0 -0
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/interaction.py +0 -0
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/policies.py +0 -0
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/preferences.py +0 -0
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/steer.py +0 -0
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/tracker.py +0 -0
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/trained_affect.py +0 -0
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/trained_breakdown.py +0 -0
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/trained_disagreement.py +0 -0
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/trained_intent.py +0 -0
- {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/trained_subtext.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: eq-layer
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Inspectable intent, affect, repair, uncertainty, and factored-action control for LLM responses
|
|
5
5
|
Project-URL: Homepage, https://github.com/Furox-Art/eq-layer
|
|
6
6
|
Project-URL: Repository, https://github.com/Furox-Art/eq-layer
|
|
@@ -300,6 +300,39 @@ machine-readable records are
|
|
|
300
300
|
`eval/results/coarse_disagreement_no_go.json`, and
|
|
301
301
|
`eval/results/dbdc3_breakdown_no_go.json`.
|
|
302
302
|
|
|
303
|
+
### Layer compute cost and decision quality
|
|
304
|
+
|
|
305
|
+
Component metrics do not say what the layer costs to run. A paired A/B run of
|
|
306
|
+
the same 15 seeded requests with the layer enabled and disabled, 100 passes
|
|
307
|
+
per arm, measured the layer's own deterministic compute with no LLM in either
|
|
308
|
+
arm:
|
|
309
|
+
|
|
310
|
+
| arm | added median (ms) | added p95 (ms) | intent holdout accuracy (36) | appropriateness rubric |
|
|
311
|
+
| --- | ---: | ---: | ---: | ---: |
|
|
312
|
+
| learned intent adapter | 1.1612 | 1.4636 | 1.000 | 0.9917 |
|
|
313
|
+
| heuristic zero-dependency | 0.1104 | 0.2265 | 0.2778 | 1.0000 |
|
|
314
|
+
|
|
315
|
+
Classification and routing are timed independently rather than lumped: with
|
|
316
|
+
the learned adapter, intent inference is ~92% of classification and routing
|
|
317
|
+
is ~2% of the total. The two adapters are not interchangeable — both score
|
|
318
|
+
1.000 on the 4 labelled seed cases, but only the learned adapter holds up on
|
|
319
|
+
the 36-case holdout. Latency is machine-dependent and moved ~9% between runs
|
|
320
|
+
on the same machine, so treat the learned figure as approximately
|
|
321
|
+
1.2 ms median rather than an exact constant.
|
|
322
|
+
|
|
323
|
+
The measured quantity is the layer's compute cost and the structural quality
|
|
324
|
+
of its decision, not response quality: there is no model in the measured path
|
|
325
|
+
to generate prose. End-to-end quality still needs the paired human evaluation
|
|
326
|
+
in `eval/AB_PROTOCOL.md`.
|
|
327
|
+
|
|
328
|
+
```bash
|
|
329
|
+
python eval/ab_latency_eval.py
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Full methodology, tables and run-to-run variation are in
|
|
333
|
+
`docs/AB_LATENCY_BENCHMARK.md`. Machine-readable record:
|
|
334
|
+
`eval/results/ab-latency.json`.
|
|
335
|
+
|
|
303
336
|
|
|
304
337
|
## Evidence provenance
|
|
305
338
|
|
|
@@ -456,6 +489,7 @@ eval/
|
|
|
456
489
|
ab_prepare.py A/B blinding
|
|
457
490
|
ab_score.py single-rater scoring
|
|
458
491
|
ab_score_multi.py multi-rater scoring
|
|
492
|
+
ab_latency_eval.py layer-on vs layer-off latency + decision-quality A/B
|
|
459
493
|
run.py
|
|
460
494
|
```
|
|
461
495
|
|
|
@@ -269,6 +269,39 @@ machine-readable records are
|
|
|
269
269
|
`eval/results/coarse_disagreement_no_go.json`, and
|
|
270
270
|
`eval/results/dbdc3_breakdown_no_go.json`.
|
|
271
271
|
|
|
272
|
+
### Layer compute cost and decision quality
|
|
273
|
+
|
|
274
|
+
Component metrics do not say what the layer costs to run. A paired A/B run of
|
|
275
|
+
the same 15 seeded requests with the layer enabled and disabled, 100 passes
|
|
276
|
+
per arm, measured the layer's own deterministic compute with no LLM in either
|
|
277
|
+
arm:
|
|
278
|
+
|
|
279
|
+
| arm | added median (ms) | added p95 (ms) | intent holdout accuracy (36) | appropriateness rubric |
|
|
280
|
+
| --- | ---: | ---: | ---: | ---: |
|
|
281
|
+
| learned intent adapter | 1.1612 | 1.4636 | 1.000 | 0.9917 |
|
|
282
|
+
| heuristic zero-dependency | 0.1104 | 0.2265 | 0.2778 | 1.0000 |
|
|
283
|
+
|
|
284
|
+
Classification and routing are timed independently rather than lumped: with
|
|
285
|
+
the learned adapter, intent inference is ~92% of classification and routing
|
|
286
|
+
is ~2% of the total. The two adapters are not interchangeable — both score
|
|
287
|
+
1.000 on the 4 labelled seed cases, but only the learned adapter holds up on
|
|
288
|
+
the 36-case holdout. Latency is machine-dependent and moved ~9% between runs
|
|
289
|
+
on the same machine, so treat the learned figure as approximately
|
|
290
|
+
1.2 ms median rather than an exact constant.
|
|
291
|
+
|
|
292
|
+
The measured quantity is the layer's compute cost and the structural quality
|
|
293
|
+
of its decision, not response quality: there is no model in the measured path
|
|
294
|
+
to generate prose. End-to-end quality still needs the paired human evaluation
|
|
295
|
+
in `eval/AB_PROTOCOL.md`.
|
|
296
|
+
|
|
297
|
+
```bash
|
|
298
|
+
python eval/ab_latency_eval.py
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Full methodology, tables and run-to-run variation are in
|
|
302
|
+
`docs/AB_LATENCY_BENCHMARK.md`. Machine-readable record:
|
|
303
|
+
`eval/results/ab-latency.json`.
|
|
304
|
+
|
|
272
305
|
|
|
273
306
|
## Evidence provenance
|
|
274
307
|
|
|
@@ -425,6 +458,7 @@ eval/
|
|
|
425
458
|
ab_prepare.py A/B blinding
|
|
426
459
|
ab_score.py single-rater scoring
|
|
427
460
|
ab_score_multi.py multi-rater scoring
|
|
461
|
+
ab_latency_eval.py layer-on vs layer-off latency + decision-quality A/B
|
|
428
462
|
run.py
|
|
429
463
|
```
|
|
430
464
|
|
|
@@ -31,8 +31,16 @@ def prepare_blinded(
|
|
|
31
31
|
pairs: list[dict],
|
|
32
32
|
*,
|
|
33
33
|
seed: int = 0,
|
|
34
|
+
arm_names: tuple[str, str] = ("eq", "baseline"),
|
|
34
35
|
) -> tuple[list[dict], dict]:
|
|
35
|
-
"""Randomize
|
|
36
|
+
"""Randomize the two response sides and return ballot + separate decode key.
|
|
37
|
+
|
|
38
|
+
arm_names labels (eq, baseline) in the decode key so the key can describe an
|
|
39
|
+
arm honestly. The primary endpoint compares the EQ response against the
|
|
40
|
+
calibrated length-matched control rather than against the raw baseline, and
|
|
41
|
+
calling that control "baseline" in the artifact would misdescribe it.
|
|
42
|
+
"""
|
|
43
|
+
eq_arm, control_arm = arm_names
|
|
36
44
|
rng = random.Random(seed)
|
|
37
45
|
ballot: list[dict] = []
|
|
38
46
|
key: dict[str, dict[str, str]] = {}
|
|
@@ -53,12 +61,13 @@ def prepare_blinded(
|
|
|
53
61
|
response_a = eq_response if eq_is_a else baseline
|
|
54
62
|
response_b = baseline if eq_is_a else eq_response
|
|
55
63
|
key[case_id] = {
|
|
56
|
-
"A":
|
|
57
|
-
"B":
|
|
64
|
+
"A": eq_arm if eq_is_a else control_arm,
|
|
65
|
+
"B": control_arm if eq_is_a else eq_arm,
|
|
58
66
|
}
|
|
59
67
|
ballot.append(
|
|
60
68
|
{
|
|
61
69
|
"id": case_id,
|
|
70
|
+
"stratum": (row.get("source") or {}).get("stratum"),
|
|
62
71
|
"context": row.get("context", []),
|
|
63
72
|
"response_A": response_a,
|
|
64
73
|
"response_B": response_b,
|
|
@@ -132,14 +141,23 @@ def summarize(wins: int, losses: int, ties: int) -> PreferenceSummary:
|
|
|
132
141
|
)
|
|
133
142
|
|
|
134
143
|
|
|
135
|
-
def score_blinded(
|
|
144
|
+
def score_blinded(
|
|
145
|
+
rated_ballot: list[dict],
|
|
146
|
+
key: dict,
|
|
147
|
+
*,
|
|
148
|
+
exclude_ids: set[str] | None = None,
|
|
149
|
+
) -> dict:
|
|
136
150
|
counts = {
|
|
137
151
|
dimension: {"wins": 0, "losses": 0, "ties": 0}
|
|
138
152
|
for dimension in DIMENSIONS
|
|
139
153
|
}
|
|
154
|
+
excluded: list[str] = []
|
|
140
155
|
|
|
141
156
|
for row in rated_ballot:
|
|
142
157
|
case_id = str(row["id"])
|
|
158
|
+
if exclude_ids and case_id in exclude_ids:
|
|
159
|
+
excluded.append(case_id)
|
|
160
|
+
continue
|
|
143
161
|
if case_id not in key:
|
|
144
162
|
raise ValueError(f"Missing decode key for case: {case_id}")
|
|
145
163
|
|
|
@@ -166,6 +184,8 @@ def score_blinded(rated_ballot: list[dict], key: dict) -> dict:
|
|
|
166
184
|
report = {
|
|
167
185
|
"design": "blind-pairwise-human-evaluation",
|
|
168
186
|
"unit": "case-level paired preference",
|
|
187
|
+
"excluded_ids": sorted(excluded),
|
|
188
|
+
"n_excluded": len(excluded),
|
|
169
189
|
"dimensions": {},
|
|
170
190
|
}
|
|
171
191
|
for dimension, values in counts.items():
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import json
|
|
5
5
|
import random
|
|
6
|
+
import re
|
|
6
7
|
import shlex
|
|
7
8
|
import subprocess
|
|
8
9
|
from dataclasses import dataclass, field
|
|
@@ -460,6 +461,117 @@ class FullEQPipeline:
|
|
|
460
461
|
return steered, metadata
|
|
461
462
|
|
|
462
463
|
|
|
464
|
+
# Surface-form limits expressed without any policy content. Used to build the
|
|
465
|
+
# length-matched control arm: the model is asked for the same amount of text it
|
|
466
|
+
# is asked for in the EQ arm, but receives no affect, intent, task or repair
|
|
467
|
+
# decision. Without it, EQ-vs-baseline measures length as much as steering.
|
|
468
|
+
#
|
|
469
|
+
# Every profile is a positive target. An earlier set was phrased as negative
|
|
470
|
+
# constraints ("no preamble, no caveats, no summary, no restatement") and was
|
|
471
|
+
# measured on a 12-case Qwen2.5-0.5B pilot: the stronger the prohibition list,
|
|
472
|
+
# the LONGER the reply. Mean characters ran 97 for a mild profile and 155 for
|
|
473
|
+
# the strictest, and one case ballooned to 450 characters under an explicit
|
|
474
|
+
# "reply in one or two sentences" instruction. A small model treats a stack of
|
|
475
|
+
# prohibitions as material to elaborate on.
|
|
476
|
+
#
|
|
477
|
+
# The EQ instruction does not enumerate prohibitions, which is the likeliest
|
|
478
|
+
# reason it stays short. The control has to be built the same way.
|
|
479
|
+
#
|
|
480
|
+
# Calibration selects among these on length alone. Tuning the control against
|
|
481
|
+
# preference outcomes would fit the nuisance variable to the result it is meant
|
|
482
|
+
# to be compared against.
|
|
483
|
+
LENGTH_CONTROL_PROFILES: dict[str, str] = {
|
|
484
|
+
# Vague targets. Measured long on a small model: 23.7 and 25.7 mean words
|
|
485
|
+
# against an EQ arm at 14.8. Kept because they show the sentence count is
|
|
486
|
+
# what does the work, not the tone of the instruction.
|
|
487
|
+
"answer_only": (
|
|
488
|
+
"Reply with only the answer to what was asked. "
|
|
489
|
+
"Ask at most {question_budget} question(s)."
|
|
490
|
+
),
|
|
491
|
+
"direct": (
|
|
492
|
+
"Reply with the direct answer, in one or two sentences. "
|
|
493
|
+
"Ask at most {question_budget} question(s) when the request is unclear."
|
|
494
|
+
),
|
|
495
|
+
# Explicit sentence-count ladder. 12.2 mean words at one sentence, so the
|
|
496
|
+
# count is the dial and the intermediate rungs are what close the gap to
|
|
497
|
+
# the EQ arm rather than one aggressive profile overshooting it.
|
|
498
|
+
"three_sentence": (
|
|
499
|
+
"Reply in three sentences at most: the answer itself. "
|
|
500
|
+
"Ask at most {question_budget} question(s) instead when the request is unclear."
|
|
501
|
+
),
|
|
502
|
+
"two_sentence": (
|
|
503
|
+
"Reply in two sentences at most: the answer itself. "
|
|
504
|
+
"Ask at most {question_budget} question(s) instead when the request is unclear."
|
|
505
|
+
),
|
|
506
|
+
"one_sentence": (
|
|
507
|
+
"Reply in one sentence: the answer itself. "
|
|
508
|
+
"Ask at most {question_budget} question(s) instead when the request is unclear."
|
|
509
|
+
),
|
|
510
|
+
"shortest_complete": (
|
|
511
|
+
"Reply with exactly what was asked for, in the fewest words that stay "
|
|
512
|
+
"complete. "
|
|
513
|
+
"Ask at most {question_budget} question(s)."
|
|
514
|
+
),
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
DEFAULT_LENGTH_PROFILE = "answer_only"
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def build_length_matched_messages(
|
|
521
|
+
messages: list[dict],
|
|
522
|
+
realization: dict,
|
|
523
|
+
profile: str = DEFAULT_LENGTH_PROFILE,
|
|
524
|
+
) -> list[dict]:
|
|
525
|
+
"""Baseline arm with only the surface budget pinned, not the EQ policy."""
|
|
526
|
+
if profile not in LENGTH_CONTROL_PROFILES:
|
|
527
|
+
raise ValueError(
|
|
528
|
+
f"Unknown length profile {profile!r}; expected one of "
|
|
529
|
+
f"{sorted(LENGTH_CONTROL_PROFILES)}"
|
|
530
|
+
)
|
|
531
|
+
budget = realization.get("question_budget", 0)
|
|
532
|
+
instruction = LENGTH_CONTROL_PROFILES[profile].format(question_budget=budget)
|
|
533
|
+
return [{"role": "system", "content": instruction}, *messages]
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
EQ_MATCHED_PROFILE = "eq_matched"
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def count_sentences(text: str) -> int:
|
|
540
|
+
parts = [part for part in re.split(r"[.!?]+", text) if part.strip()]
|
|
541
|
+
return len(parts)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def build_eq_matched_messages(
|
|
545
|
+
messages: list[dict],
|
|
546
|
+
realization: dict,
|
|
547
|
+
eq_response: str,
|
|
548
|
+
) -> list[dict]:
|
|
549
|
+
"""Control arm whose sentence budget is read off the EQ arm's own output.
|
|
550
|
+
|
|
551
|
+
The fixed ladder bottoms out around 11 percent off on 120 cases because
|
|
552
|
+
"reply in one sentence" does not reliably land on one sentence, and the
|
|
553
|
+
ladder has no rung between one and two. Reading the target off the EQ
|
|
554
|
+
response closes that per case instead of on average.
|
|
555
|
+
|
|
556
|
+
This couples the two arms: the control is no longer independent of EQ. The
|
|
557
|
+
bias runs in the conservative direction. The control is told a sentence
|
|
558
|
+
count, never a decision, so it cannot inherit what EQ decided; but a
|
|
559
|
+
response shaped like EQ will more often come out similar in form, which
|
|
560
|
+
pushes disagreement toward ties. That would hide an EQ win rather than
|
|
561
|
+
manufacture one, which is the safer direction for a control to err in.
|
|
562
|
+
|
|
563
|
+
Selection still reads length only. Preference outcomes are not consulted.
|
|
564
|
+
"""
|
|
565
|
+
target = min(max(count_sentences(eq_response), 1), 4)
|
|
566
|
+
budget = realization.get("question_budget", 0)
|
|
567
|
+
unit = "sentence" if target == 1 else "sentences"
|
|
568
|
+
instruction = (
|
|
569
|
+
f"Reply in {target} {unit} at most: the answer itself. "
|
|
570
|
+
f"Ask at most {budget} question(s) instead when the request is unclear."
|
|
571
|
+
)
|
|
572
|
+
return [{"role": "system", "content": instruction}, *messages]
|
|
573
|
+
|
|
574
|
+
|
|
463
575
|
def generate_pairs(
|
|
464
576
|
cases: Iterable[dict],
|
|
465
577
|
*,
|
|
@@ -468,9 +580,18 @@ def generate_pairs(
|
|
|
468
580
|
seed: int = 0,
|
|
469
581
|
allow_annotations: bool = False,
|
|
470
582
|
auditor_regenerations: int = 0,
|
|
583
|
+
length_matched_arm: bool = False,
|
|
584
|
+
length_profiles: tuple[str, ...] | None = None,
|
|
471
585
|
) -> list[dict]:
|
|
472
586
|
if auditor_regenerations not in {0, 1}:
|
|
473
587
|
raise ValueError("auditor_regenerations must be 0 or 1.")
|
|
588
|
+
profiles = tuple(length_profiles or ())
|
|
589
|
+
for name in profiles:
|
|
590
|
+
if name not in LENGTH_CONTROL_PROFILES and name != EQ_MATCHED_PROFILE:
|
|
591
|
+
raise ValueError(
|
|
592
|
+
f"Unknown length profile {name!r}; expected one of "
|
|
593
|
+
f"{sorted(LENGTH_CONTROL_PROFILES)}"
|
|
594
|
+
)
|
|
474
595
|
|
|
475
596
|
rng = random.Random(seed)
|
|
476
597
|
output: list[dict] = []
|
|
@@ -494,12 +615,43 @@ def generate_pairs(
|
|
|
494
615
|
)
|
|
495
616
|
|
|
496
617
|
pair_seed = stable_case_seed(seed, case_id)
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
618
|
+
realization = (eq_meta.get("factored_action") or {}).get("realization") or {}
|
|
619
|
+
# One arm per profile so a single generation pass covers the whole
|
|
620
|
+
# calibration sweep. Each arm is an independent condition with the
|
|
621
|
+
# same model, temperature and per-case seed.
|
|
622
|
+
controls = [f"length_matched:{name}" for name in profiles]
|
|
623
|
+
if length_matched_arm and not profiles:
|
|
624
|
+
controls.append("length_matched")
|
|
625
|
+
|
|
626
|
+
# EQ is generated first because the eq_matched control reads its
|
|
627
|
+
# sentence budget off the EQ response. Shuffling the control arms
|
|
628
|
+
# among themselves keeps them from being generated in a fixed order.
|
|
629
|
+
responses: dict[str, str] = {
|
|
630
|
+
"baseline": model.generate(messages, seed=pair_seed, condition="baseline"),
|
|
631
|
+
"eq": model.generate(eq_messages, seed=pair_seed, condition="eq"),
|
|
632
|
+
}
|
|
633
|
+
rng.shuffle(controls)
|
|
500
634
|
|
|
501
|
-
for condition in
|
|
502
|
-
|
|
635
|
+
for condition in controls:
|
|
636
|
+
if condition == "length_matched":
|
|
637
|
+
condition_messages = build_length_matched_messages(
|
|
638
|
+
messages,
|
|
639
|
+
realization,
|
|
640
|
+
DEFAULT_LENGTH_PROFILE,
|
|
641
|
+
)
|
|
642
|
+
elif condition == "length_matched:eq_matched":
|
|
643
|
+
condition_messages = build_eq_matched_messages(
|
|
644
|
+
messages,
|
|
645
|
+
realization,
|
|
646
|
+
responses["eq"],
|
|
647
|
+
)
|
|
648
|
+
else:
|
|
649
|
+
profile = condition.split(":", 1)[1]
|
|
650
|
+
condition_messages = build_length_matched_messages(
|
|
651
|
+
messages,
|
|
652
|
+
realization,
|
|
653
|
+
profile,
|
|
654
|
+
)
|
|
503
655
|
responses[condition] = model.generate(
|
|
504
656
|
condition_messages,
|
|
505
657
|
seed=pair_seed,
|
|
@@ -545,7 +697,7 @@ def generate_pairs(
|
|
|
545
697
|
"model_id": model.model_id,
|
|
546
698
|
"temperature": model.temperature,
|
|
547
699
|
"pair_seed": pair_seed,
|
|
548
|
-
"order":
|
|
700
|
+
"order": ["baseline", "eq", *controls],
|
|
549
701
|
"same_model_both_arms": True,
|
|
550
702
|
"annotations_used": bool(annotated),
|
|
551
703
|
"auditor_regenerations_allowed": auditor_regenerations,
|
|
@@ -557,6 +709,12 @@ def generate_pairs(
|
|
|
557
709
|
}
|
|
558
710
|
if regeneration_count:
|
|
559
711
|
record["eq_initial"] = initial_eq
|
|
712
|
+
for name in profiles:
|
|
713
|
+
record[f"length_matched_{name}"] = responses[f"length_matched:{name}"]
|
|
714
|
+
if length_matched_arm and not profiles:
|
|
715
|
+
record["length_matched"] = responses["length_matched"]
|
|
716
|
+
if profiles:
|
|
717
|
+
record["length_profiles"] = list(profiles)
|
|
560
718
|
|
|
561
719
|
output.append(record)
|
|
562
720
|
finally:
|
|
@@ -575,9 +733,38 @@ def experiment_manifest(
|
|
|
575
733
|
allow_annotations: bool,
|
|
576
734
|
git_commit: str | None = None,
|
|
577
735
|
auditor_regenerations: int = 0,
|
|
736
|
+
length_matched_arm: bool = False,
|
|
737
|
+
length_profiles: tuple[str, ...] = (),
|
|
578
738
|
) -> dict:
|
|
739
|
+
profiles = list(length_profiles)
|
|
740
|
+
# Reject unknown names here as well as at generation time. The manifest is the
|
|
741
|
+
# record other people read, so an unresolvable profile must not be written
|
|
742
|
+
# into it and left to fail later.
|
|
743
|
+
unknown = [
|
|
744
|
+
name
|
|
745
|
+
for name in profiles
|
|
746
|
+
if name not in LENGTH_CONTROL_PROFILES and name != EQ_MATCHED_PROFILE
|
|
747
|
+
]
|
|
748
|
+
if unknown:
|
|
749
|
+
raise ValueError(
|
|
750
|
+
f"Unknown length profile(s) {unknown}; expected one of "
|
|
751
|
+
f"{sorted(LENGTH_CONTROL_PROFILES)}"
|
|
752
|
+
)
|
|
753
|
+
arms = (
|
|
754
|
+
["baseline", "eq"]
|
|
755
|
+
+ [f"length_matched:{name}" for name in profiles]
|
|
756
|
+
+ (["length_matched"] if length_matched_arm and not profiles else [])
|
|
757
|
+
)
|
|
579
758
|
return {
|
|
580
|
-
"design":
|
|
759
|
+
"design": (
|
|
760
|
+
"same-base-model length-calibration sweep"
|
|
761
|
+
if profiles
|
|
762
|
+
else (
|
|
763
|
+
"same-base-model three-arm baseline-vs-length-matched-vs-eq-layer"
|
|
764
|
+
if length_matched_arm
|
|
765
|
+
else "same-base-model paired baseline-vs-eq-layer"
|
|
766
|
+
)
|
|
767
|
+
),
|
|
581
768
|
"model_id": model.model_id,
|
|
582
769
|
"model_command_executable": model.command[0],
|
|
583
770
|
"model_command_argv_sha256": hashlib.sha256(
|
|
@@ -599,8 +786,41 @@ def experiment_manifest(
|
|
|
599
786
|
"subtext_model_sha256": file_sha256(subtext_model),
|
|
600
787
|
"eq_layer_git_commit": git_commit,
|
|
601
788
|
"pairing_rule": "same model id, same sampling temperature, same per-case seed",
|
|
789
|
+
"arms": arms,
|
|
790
|
+
"length_matched_arm": length_matched_arm,
|
|
791
|
+
"length_profiles": profiles,
|
|
792
|
+
"length_profile_selection": (
|
|
793
|
+
"Chosen on arm length only. The control must not be tuned against "
|
|
794
|
+
"preference outcomes, or the nuisance variable gets fitted to the "
|
|
795
|
+
"result it exists to be compared against."
|
|
796
|
+
),
|
|
797
|
+
"eq_matched_arm_coupling": (
|
|
798
|
+
"When eq_matched is swept, its sentence budget is read off the EQ "
|
|
799
|
+
"response, so the control is not independent of EQ. The bias runs "
|
|
800
|
+
"toward ties, not toward an EQ win: the control receives a sentence "
|
|
801
|
+
"count and never the decision."
|
|
802
|
+
)
|
|
803
|
+
if EQ_MATCHED_PROFILE in profiles
|
|
804
|
+
else None,
|
|
602
805
|
"arm_difference": (
|
|
603
806
|
"EQ arm prepends only the EQ-Layer system control instruction; "
|
|
604
807
|
"the original transcript is preserved identically in both arms."
|
|
605
808
|
),
|
|
809
|
+
"length_control": (
|
|
810
|
+
"The length-matched arm pins only the surface budget (verbosity, "
|
|
811
|
+
"question_budget) with no affect, intent, task or repair decision. "
|
|
812
|
+
"EQ-vs-length_matched isolates the deterministic policy contribution; "
|
|
813
|
+
"EQ-vs-baseline does not, because the EQ arm runs roughly 3x shorter "
|
|
814
|
+
"than the unconstrained baseline."
|
|
815
|
+
),
|
|
816
|
+
"claim_boundary": (
|
|
817
|
+
"EQ-vs-baseline mixes the deterministic decision with the surface budget "
|
|
818
|
+
"it implies. Only EQ-vs-length_matched measures the deterministic layer "
|
|
819
|
+
"alone, and even that leaves a measured residual: on 120 cases the best "
|
|
820
|
+
"calibrated control sat 11.2 percent off, and deriving the control's "
|
|
821
|
+
"sentence budget from the EQ response made it worse, not better, because "
|
|
822
|
+
"EQ's brevity comes from its decision rather than from a surface budget. "
|
|
823
|
+
"The primary endpoint is therefore the routing decision, scored by "
|
|
824
|
+
"eval/route_score.py, not response preference."
|
|
825
|
+
),
|
|
606
826
|
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|