eq-layer 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,68 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from .affect import AffectAdapter, HeuristicAffect, load_cases
4
+ from .auditor import AuditContext, AuditIssue, ResponseAudit, ResponseAuditor, audit_from_generation_metadata
5
+ from .actions import FactoredAction, RealizationControls, compose_action
6
+ from .intent import HeuristicIntent, IntentAdapter, IntentState
7
+ from .intent_belief import IntentBelief, IntentRiskDecision, decide_intent_action, expected_loss
8
+ from .interaction import InteractionQualityState, RepairState, infer_interaction_quality, infer_repair_state
9
+ from .policies import POLICIES, REGISTRY, SUBTEXTS, AffectState, Policy, Register, Selector
10
+ from .preferences import SessionPreferences, infer_session_preferences
11
+ from .response_experiment import CommandModel, FullEQPipeline, generate_pairs, validate_experiment_cases
12
+ from .steer import Steer, aggregate, score_case
13
+ from .trained_affect import DimensionalAffect, TrainedAffect
14
+ from .trained_intent import TrainedIntent
15
+ from .trained_subtext import DialogueSignals, SubtextDecision, TrainedSubtext
16
+ from .tracker import AnnotationStanceResolver, ConversationTracker, StanceDecision, TrackingResult
17
+
18
+ __all__ = [
19
+ "__version__",
20
+ "AffectAdapter",
21
+ "AffectState",
22
+ "audit_from_generation_metadata",
23
+ "ResponseAuditor",
24
+ "ResponseAudit",
25
+ "AuditIssue",
26
+ "AuditContext",
27
+ "compose_action",
28
+ "RealizationControls",
29
+ "FactoredAction",
30
+ "HeuristicAffect",
31
+ "HeuristicIntent",
32
+ "IntentAdapter",
33
+ "IntentState",
34
+ "expected_loss",
35
+ "decide_intent_action",
36
+ "IntentRiskDecision",
37
+ "IntentBelief",
38
+ "infer_repair_state",
39
+ "infer_interaction_quality",
40
+ "RepairState",
41
+ "InteractionQualityState",
42
+ "POLICIES",
43
+ "Policy",
44
+ "REGISTRY",
45
+ "Register",
46
+ "SUBTEXTS",
47
+ "Selector",
48
+ "infer_session_preferences",
49
+ "SessionPreferences",
50
+ "Steer",
51
+ "TrainedIntent",
52
+ "TrainedAffect",
53
+ "DimensionalAffect",
54
+ "aggregate",
55
+ "load_cases",
56
+ "TrainedSubtext",
57
+ "DialogueSignals",
58
+ "SubtextDecision",
59
+ "ConversationTracker",
60
+ "TrackingResult",
61
+ "StanceDecision",
62
+ "AnnotationStanceResolver",
63
+ "CommandModel",
64
+ "FullEQPipeline",
65
+ "generate_pairs",
66
+ "validate_experiment_cases",
67
+ "score_case",
68
+ ]
eq_layer/ab_eval.py ADDED
@@ -0,0 +1,302 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ import random
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+
10
+ DIMENSIONS = (
11
+ "intent_fidelity",
12
+ "appropriateness",
13
+ "actionability",
14
+ "non_patronizing",
15
+ "overall",
16
+ )
17
+
18
+
19
+ def load_jsonl(path: str | Path) -> list[dict]:
20
+ with open(path, encoding="utf-8") as fh:
21
+ return [json.loads(line) for line in fh if line.strip()]
22
+
23
+
24
+ def write_jsonl(path: str | Path, rows: list[dict]) -> None:
25
+ with open(path, "w", encoding="utf-8") as fh:
26
+ for row in rows:
27
+ fh.write(json.dumps(row, ensure_ascii=False) + "\n")
28
+
29
+
30
+ def prepare_blinded(
31
+ pairs: list[dict],
32
+ *,
33
+ seed: int = 0,
34
+ ) -> tuple[list[dict], dict]:
35
+ """Randomize baseline/EQ sides and return ballot + separate decode key."""
36
+ rng = random.Random(seed)
37
+ ballot: list[dict] = []
38
+ key: dict[str, dict[str, str]] = {}
39
+
40
+ seen: set[str] = set()
41
+ for row in pairs:
42
+ case_id = str(row["id"])
43
+ if case_id in seen:
44
+ raise ValueError(f"Duplicate A/B case id: {case_id}")
45
+ seen.add(case_id)
46
+
47
+ baseline = str(row["baseline"])
48
+ eq_response = str(row["eq"])
49
+ if not baseline.strip() or not eq_response.strip():
50
+ raise ValueError(f"Empty response in A/B case: {case_id}")
51
+
52
+ eq_is_a = bool(rng.getrandbits(1))
53
+ response_a = eq_response if eq_is_a else baseline
54
+ response_b = baseline if eq_is_a else eq_response
55
+ key[case_id] = {
56
+ "A": "eq" if eq_is_a else "baseline",
57
+ "B": "baseline" if eq_is_a else "eq",
58
+ }
59
+ ballot.append(
60
+ {
61
+ "id": case_id,
62
+ "context": row.get("context", []),
63
+ "response_A": response_a,
64
+ "response_B": response_b,
65
+ "ratings": {dimension: "" for dimension in DIMENSIONS},
66
+ }
67
+ )
68
+
69
+ return ballot, key
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class PreferenceSummary:
74
+ wins: int
75
+ losses: int
76
+ ties: int
77
+ win_rate_non_tie: float | None
78
+ wilson_low: float | None
79
+ wilson_high: float | None
80
+ exact_p_two_sided: float | None
81
+
82
+
83
+ def _wilson_interval(successes: int, total: int, z: float = 1.959963984540054) -> tuple[float, float]:
84
+ if total <= 0:
85
+ raise ValueError("Wilson interval requires total > 0.")
86
+ p = successes / total
87
+ denom = 1.0 + (z * z) / total
88
+ centre = (p + (z * z) / (2.0 * total)) / denom
89
+ margin = (
90
+ z
91
+ * math.sqrt((p * (1.0 - p) + (z * z) / (4.0 * total)) / total)
92
+ / denom
93
+ )
94
+ return max(0.0, centre - margin), min(1.0, centre + margin)
95
+
96
+
97
+ def _binom_two_sided_half(successes: int, total: int) -> float:
98
+ """Exact two-sided sign test under p=0.5, summing equally/less likely outcomes."""
99
+ if total <= 0:
100
+ raise ValueError("Binomial test requires total > 0.")
101
+ observed = math.comb(total, successes)
102
+ numerator = 0
103
+ for k in range(total + 1):
104
+ ways = math.comb(total, k)
105
+ if ways <= observed:
106
+ numerator += ways
107
+ return min(1.0, numerator / (2 ** total))
108
+
109
+
110
+ def summarize(wins: int, losses: int, ties: int) -> PreferenceSummary:
111
+ non_ties = wins + losses
112
+ if non_ties == 0:
113
+ return PreferenceSummary(
114
+ wins=wins,
115
+ losses=losses,
116
+ ties=ties,
117
+ win_rate_non_tie=None,
118
+ wilson_low=None,
119
+ wilson_high=None,
120
+ exact_p_two_sided=None,
121
+ )
122
+
123
+ low, high = _wilson_interval(wins, non_ties)
124
+ return PreferenceSummary(
125
+ wins=wins,
126
+ losses=losses,
127
+ ties=ties,
128
+ win_rate_non_tie=round(wins / non_ties, 4),
129
+ wilson_low=round(low, 4),
130
+ wilson_high=round(high, 4),
131
+ exact_p_two_sided=round(_binom_two_sided_half(wins, non_ties), 6),
132
+ )
133
+
134
+
135
+ def score_blinded(rated_ballot: list[dict], key: dict) -> dict:
136
+ counts = {
137
+ dimension: {"wins": 0, "losses": 0, "ties": 0}
138
+ for dimension in DIMENSIONS
139
+ }
140
+
141
+ for row in rated_ballot:
142
+ case_id = str(row["id"])
143
+ if case_id not in key:
144
+ raise ValueError(f"Missing decode key for case: {case_id}")
145
+
146
+ ratings = row.get("ratings") or {}
147
+ for dimension in DIMENSIONS:
148
+ vote = str(ratings.get(dimension, "")).strip().upper()
149
+ if vote not in {"A", "B", "TIE"}:
150
+ raise ValueError(
151
+ f"{case_id}/{dimension}: expected A, B, or tie; got {vote!r}"
152
+ )
153
+
154
+ if vote == "TIE":
155
+ counts[dimension]["ties"] += 1
156
+ continue
157
+
158
+ winner = key[case_id][vote]
159
+ if winner == "eq":
160
+ counts[dimension]["wins"] += 1
161
+ elif winner == "baseline":
162
+ counts[dimension]["losses"] += 1
163
+ else:
164
+ raise ValueError(f"Invalid decode key target: {winner}")
165
+
166
+ report = {
167
+ "design": "blind-pairwise-human-evaluation",
168
+ "unit": "case-level paired preference",
169
+ "dimensions": {},
170
+ }
171
+ for dimension, values in counts.items():
172
+ summary = summarize(**values)
173
+ report["dimensions"][dimension] = {
174
+ "eq_wins": summary.wins,
175
+ "eq_losses": summary.losses,
176
+ "ties": summary.ties,
177
+ "eq_win_rate_non_tie": summary.win_rate_non_tie,
178
+ "wilson_95_low": summary.wilson_low,
179
+ "wilson_95_high": summary.wilson_high,
180
+ "exact_sign_test_p_two_sided": summary.exact_p_two_sided,
181
+ }
182
+ return report
183
+
184
+
185
+ def _decoded_vote(row: dict, key: dict, dimension: str) -> str:
186
+ case_id = str(row["id"])
187
+ if case_id not in key:
188
+ raise ValueError(f"Missing decode key for case: {case_id}")
189
+ ratings = row.get("ratings") or {}
190
+ vote = str(ratings.get(dimension, "")).strip().upper()
191
+ if vote == "TIE":
192
+ return "tie"
193
+ if vote not in {"A", "B"}:
194
+ raise ValueError(
195
+ f"{case_id}/{dimension}: expected A, B, or tie; got {vote!r}"
196
+ )
197
+ winner = key[case_id][vote]
198
+ if winner not in {"eq", "baseline"}:
199
+ raise ValueError(f"Invalid decode key target: {winner}")
200
+ return winner
201
+
202
+
203
+ def _fleiss_kappa(case_votes: list[list[str]]) -> float | None:
204
+ if not case_votes:
205
+ return None
206
+ n_raters = len(case_votes[0])
207
+ if n_raters < 2 or any(len(votes) != n_raters for votes in case_votes):
208
+ return None
209
+
210
+ categories = ("eq", "baseline", "tie")
211
+ p_i = []
212
+ totals = {category: 0 for category in categories}
213
+
214
+ for votes in case_votes:
215
+ counts = {category: votes.count(category) for category in categories}
216
+ for category in categories:
217
+ totals[category] += counts[category]
218
+ agreement = (
219
+ sum(count * count for count in counts.values()) - n_raters
220
+ ) / (n_raters * (n_raters - 1))
221
+ p_i.append(agreement)
222
+
223
+ p_bar = sum(p_i) / len(p_i)
224
+ denom = len(case_votes) * n_raters
225
+ marginals = {
226
+ category: totals[category] / denom
227
+ for category in categories
228
+ }
229
+ p_e = sum(value * value for value in marginals.values())
230
+ if math.isclose(1.0 - p_e, 0.0):
231
+ return None
232
+ return round((p_bar - p_e) / (1.0 - p_e), 4)
233
+
234
+
235
+ def score_multiple_blinded(
236
+ rated_ballots: dict[str, list[dict]],
237
+ key: dict,
238
+ ) -> dict:
239
+ """Aggregate multiple blinded raters without treating rater-votes as cases."""
240
+ if len(rated_ballots) < 2:
241
+ raise ValueError("Multi-rater scoring requires at least two raters.")
242
+
243
+ case_ids: list[str] | None = None
244
+ rows_by_rater: dict[str, dict[str, dict]] = {}
245
+
246
+ for rater_id, ballot in rated_ballots.items():
247
+ mapping = {str(row["id"]): row for row in ballot}
248
+ if len(mapping) != len(ballot):
249
+ raise ValueError(f"Duplicate case id in rater ballot: {rater_id}")
250
+ current_ids = sorted(mapping)
251
+ if case_ids is None:
252
+ case_ids = current_ids
253
+ elif current_ids != case_ids:
254
+ raise ValueError("All raters must score the same case ids.")
255
+ rows_by_rater[rater_id] = mapping
256
+
257
+ assert case_ids is not None
258
+ report = {
259
+ "design": "blind-pairwise-multi-rater-human-evaluation",
260
+ "n_raters": len(rated_ballots),
261
+ "n_cases": len(case_ids),
262
+ "dimensions": {},
263
+ "rater_reports": {},
264
+ }
265
+
266
+ for rater_id, ballot in rated_ballots.items():
267
+ report["rater_reports"][rater_id] = score_blinded(ballot, key)
268
+
269
+ for dimension in DIMENSIONS:
270
+ majority_wins = 0
271
+ majority_losses = 0
272
+ majority_ties = 0
273
+ per_case_votes: list[list[str]] = []
274
+
275
+ for case_id in case_ids:
276
+ votes = [
277
+ _decoded_vote(rows_by_rater[rater_id][case_id], key, dimension)
278
+ for rater_id in sorted(rows_by_rater)
279
+ ]
280
+ per_case_votes.append(votes)
281
+ eq_votes = votes.count("eq")
282
+ baseline_votes = votes.count("baseline")
283
+ if eq_votes > baseline_votes:
284
+ majority_wins += 1
285
+ elif baseline_votes > eq_votes:
286
+ majority_losses += 1
287
+ else:
288
+ majority_ties += 1
289
+
290
+ summary = summarize(majority_wins, majority_losses, majority_ties)
291
+ report["dimensions"][dimension] = {
292
+ "case_majority_eq_wins": summary.wins,
293
+ "case_majority_eq_losses": summary.losses,
294
+ "case_majority_ties": summary.ties,
295
+ "case_majority_eq_win_rate_non_tie": summary.win_rate_non_tie,
296
+ "wilson_95_low": summary.wilson_low,
297
+ "wilson_95_high": summary.wilson_high,
298
+ "exact_sign_test_p_two_sided": summary.exact_p_two_sided,
299
+ "fleiss_kappa": _fleiss_kappa(per_case_votes),
300
+ }
301
+
302
+ return report
eq_layer/actions.py ADDED
@@ -0,0 +1,242 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+
5
+ from .dialogue_reference import DialogueReferenceState
6
+ from .intent import IntentState
7
+ from .interaction import InteractionQualityState, RepairState
8
+ from .policies import Policy, Register
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class RealizationControls:
13
+ """Surface-form controls kept separate from task/social/repair decisions."""
14
+
15
+ verbosity: str = "normal"
16
+ directness: str = "normal"
17
+ warmth: str = "normal"
18
+ question_budget: int = 0
19
+ scope_limited: bool = False
20
+ no_guess: bool = False
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class FactoredAction:
25
+ """Inspectable response action compiled from intent and dialogue state.
26
+
27
+ task_move is driven primarily by the user's explicit goal.
28
+ social_move and repair_move are driven by conversational state/policy.
29
+ This prevents affective adaptation from silently replacing the task.
30
+ """
31
+
32
+ task_move: str
33
+ social_move: str
34
+ repair_move: str
35
+ realization: RealizationControls
36
+ source_policy: str
37
+
38
+ def to_dict(self) -> dict:
39
+ return asdict(self)
40
+
41
+
42
+ def _task_move(
43
+ intent: IntentState | None,
44
+ policy: Policy,
45
+ repair: RepairState | None = None,
46
+ reference: DialogueReferenceState | None = None,
47
+ ) -> str:
48
+ if repair is not None and repair.active:
49
+ if repair.replacement_explicit:
50
+ return "resume_prior_task_with_correction"
51
+ return "clarify_correction"
52
+
53
+ if reference is not None and reference.active:
54
+ if not reference.resolved:
55
+ return "clarify_reference"
56
+ return {
57
+ "continue": "continue_referenced_goal",
58
+ "execute_reference": "execute_referenced_goal",
59
+ "revise_reference": "revise_referenced_output",
60
+ }.get(reference.form, "respond_contextually")
61
+
62
+ if intent is not None:
63
+ if intent.needs_clarification:
64
+ return "clarify_goal"
65
+ return {
66
+ "action_request": "execute_request",
67
+ "status_check": "report_status",
68
+ "explanation": "explain",
69
+ "question": "answer_question",
70
+ "statement": "respond_contextually",
71
+ "unknown": "clarify_goal",
72
+ }.get(intent.kind, "respond_contextually")
73
+
74
+ return {
75
+ "execute_request": "execute_request",
76
+ "report_status": "report_status",
77
+ "explain_request": "explain",
78
+ "clarify_request": "clarify_goal",
79
+ "direct_no_padding": "answer_question",
80
+ "boundary": "set_boundary",
81
+ }.get(policy.name, "respond_contextually")
82
+
83
+
84
+ SOCIAL_MOVES = {
85
+ "mirror_specific": "mirror_specific",
86
+ "validate_then_redirect": "validate_specific",
87
+ "deflate_tension": "deflate_tension",
88
+ "hold": "minimal_presence",
89
+ "boundary": "boundary_neutral",
90
+ "hold_sustained_escalation": "low_warmth",
91
+ }
92
+
93
+ REPAIR_MOVES = {
94
+ "clarify_request": "clarify_goal",
95
+ "ask_one_question": "clarify_one",
96
+ "repair_hold_position": "hold_position",
97
+ "repair_interrogation": "stop_restatement",
98
+ }
99
+
100
+
101
+ def _repair_move(policy: Policy, repair: RepairState | None) -> str:
102
+ base = REPAIR_MOVES.get(policy.name, "none")
103
+ if repair is None or not repair.active:
104
+ return base
105
+ if repair.replacement_explicit:
106
+ if repair.repeated:
107
+ return "stop_restatement_and_apply_correction"
108
+ return "apply_correction"
109
+ if repair.repeated:
110
+ return "stop_restatement_and_repair"
111
+ if base != "none":
112
+ return base
113
+ return "clarify_one"
114
+
115
+
116
+ def _realization(
117
+ policy: Policy,
118
+ intent: IntentState | None,
119
+ *,
120
+ repair_move: str,
121
+ repair: RepairState | None = None,
122
+ interaction_quality: InteractionQualityState | None = None,
123
+ reference: DialogueReferenceState | None = None,
124
+ ) -> RealizationControls:
125
+ by_register = {
126
+ Register.MIRROR: ("normal", "normal", "normal"),
127
+ Register.VALIDATE_REDIRECT: ("normal", "high", "normal"),
128
+ Register.DIRECT: ("normal", "high", "low"),
129
+ Register.ASK: ("low", "high", "normal"),
130
+ Register.DEFLATE: ("low", "high", "low"),
131
+ Register.REPAIR: ("low", "high", "low"),
132
+ Register.HOLD: ("low", "normal", "low"),
133
+ Register.BOUNDARY: ("low", "high", "low"),
134
+ }
135
+ verbosity, directness, warmth = by_register[policy.register]
136
+
137
+ constraints = set(intent.constraints) if intent is not None else set()
138
+ if "brief" in constraints:
139
+ verbosity = "low"
140
+ if "detailed" in constraints:
141
+ verbosity = "high"
142
+ if "direct" in constraints:
143
+ directness = "high"
144
+
145
+ # Interaction quality is not affect. Only a strong structural failure
146
+ # signal changes realization, and it does so conservatively.
147
+ if interaction_quality is not None and interaction_quality.current <= 0.55:
148
+ verbosity = "low"
149
+ directness = "high"
150
+ warmth = "low"
151
+
152
+ explicit_replacement = (
153
+ repair is not None
154
+ and repair.active
155
+ and repair.replacement_explicit
156
+ )
157
+ repair_needs_clarification = (
158
+ repair is not None
159
+ and repair.active
160
+ and not repair.replacement_explicit
161
+ )
162
+ reference_needs_clarification = (
163
+ reference is not None
164
+ and reference.active
165
+ and not reference.resolved
166
+ )
167
+ question_budget = 1 if (
168
+ explicit_replacement
169
+ or repair_needs_clarification
170
+ or reference_needs_clarification
171
+ or repair_move in {"clarify_goal", "clarify_one"}
172
+ or (
173
+ intent is not None
174
+ and intent.needs_clarification
175
+ and not explicit_replacement
176
+ )
177
+ ) else 0
178
+
179
+ no_guess = (
180
+ "no_guess" in constraints
181
+ or repair_needs_clarification
182
+ or reference_needs_clarification
183
+ or (
184
+ intent is not None
185
+ and intent.needs_clarification
186
+ and not explicit_replacement
187
+ )
188
+ )
189
+
190
+ # An active correction identifies a repair target, but does not prove the
191
+ # user's factual stance. Do not promote it to user_right/user_wrong.
192
+ if repair is not None and repair.active and repair_move == "clarify_one":
193
+ no_guess = True
194
+
195
+ return RealizationControls(
196
+ verbosity=verbosity,
197
+ directness=directness,
198
+ warmth=warmth,
199
+ question_budget=question_budget,
200
+ scope_limited="scope_limited" in constraints,
201
+ no_guess=no_guess,
202
+ )
203
+
204
+
205
+ def compose_action(
206
+ policy: Policy,
207
+ intent: IntentState | None = None,
208
+ *,
209
+ repair: RepairState | None = None,
210
+ interaction_quality: InteractionQualityState | None = None,
211
+ reference: DialogueReferenceState | None = None,
212
+ ) -> FactoredAction:
213
+ """Compile policy + intent + dialogue-health state into orthogonal controls."""
214
+
215
+ task_move = _task_move(intent, policy, repair, reference)
216
+ if policy.name == "boundary":
217
+ task_move = "set_boundary"
218
+
219
+ repair_move = _repair_move(policy, repair)
220
+ social_move = SOCIAL_MOVES.get(policy.name, "neutral")
221
+
222
+ if (
223
+ interaction_quality is not None
224
+ and interaction_quality.current <= 0.55
225
+ and social_move == "neutral"
226
+ ):
227
+ social_move = "low_warmth"
228
+
229
+ return FactoredAction(
230
+ task_move=task_move,
231
+ social_move=social_move,
232
+ repair_move=repair_move,
233
+ realization=_realization(
234
+ policy,
235
+ intent,
236
+ repair_move=repair_move,
237
+ repair=repair,
238
+ interaction_quality=interaction_quality,
239
+ reference=reference,
240
+ ),
241
+ source_policy=policy.name,
242
+ )