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.
Files changed (30) hide show
  1. {eq_layer-0.1.0 → eq_layer-0.2.0}/PKG-INFO +35 -1
  2. {eq_layer-0.1.0 → eq_layer-0.2.0}/README.md +34 -0
  3. {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/__init__.py +1 -1
  4. {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/ab_eval.py +24 -4
  5. {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/cli.py +1 -1
  6. {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/response_experiment.py +227 -7
  7. {eq_layer-0.1.0 → eq_layer-0.2.0}/pyproject.toml +1 -1
  8. {eq_layer-0.1.0 → eq_layer-0.2.0}/.gitignore +0 -0
  9. {eq_layer-0.1.0 → eq_layer-0.2.0}/LICENSE +0 -0
  10. {eq_layer-0.1.0 → eq_layer-0.2.0}/THIRD_PARTY_DATA.md +0 -0
  11. {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/actions.py +0 -0
  12. {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/affect.py +0 -0
  13. {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/auditor.py +0 -0
  14. {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/coarse_discourse.py +0 -0
  15. {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/data/intent_train.jsonl +0 -0
  16. {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/dbdc3.py +0 -0
  17. {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/dialogbank_iso.py +0 -0
  18. {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/dialogue_reference.py +0 -0
  19. {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/intent.py +0 -0
  20. {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/intent_belief.py +0 -0
  21. {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/interaction.py +0 -0
  22. {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/policies.py +0 -0
  23. {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/preferences.py +0 -0
  24. {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/steer.py +0 -0
  25. {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/tracker.py +0 -0
  26. {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/trained_affect.py +0 -0
  27. {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/trained_breakdown.py +0 -0
  28. {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/trained_disagreement.py +0 -0
  29. {eq_layer-0.1.0 → eq_layer-0.2.0}/eq_layer/trained_intent.py +0 -0
  30. {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.1.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
 
@@ -1,4 +1,4 @@
1
- __version__ = "0.1.0"
1
+ __version__ = "0.2.0"
2
2
 
3
3
  from .affect import AffectAdapter, HeuristicAffect, load_cases
4
4
  from .auditor import AuditContext, AuditIssue, ResponseAudit, ResponseAuditor, audit_from_generation_metadata
@@ -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 baseline/EQ sides and return ballot + separate decode key."""
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": "eq" if eq_is_a else "baseline",
57
- "B": "baseline" if eq_is_a else "eq",
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(rated_ballot: list[dict], key: dict) -> dict:
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():
@@ -127,7 +127,7 @@ def _route(args: argparse.Namespace) -> int:
127
127
  def _doctor() -> int:
128
128
  payload = {
129
129
  "package": "eq-layer",
130
- "version": "0.1.0",
130
+ "version": "0.2.0",
131
131
  "python": sys.version.split()[0],
132
132
  "ml_extra_available": True,
133
133
  }
@@ -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
- conditions = ["baseline", "eq"]
498
- rng.shuffle(conditions)
499
- responses: dict[str, str] = {}
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 conditions:
502
- condition_messages = messages if condition == "baseline" else eq_messages
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": conditions,
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": "same-base-model paired baseline-vs-eq-layer",
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
  }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "eq-layer"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Inspectable intent, affect, repair, uncertainty, and factored-action control for LLM responses"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
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