Files
opensquilla--opensquilla/tests/test_router_self_learning_feedback_align.py
2026-07-13 13:12:33 +08:00

628 lines
22 KiB
Python

"""Explicit-feedback consumption: alignment reasons, weights, gates, rollback.
The load-bearing invariant: ``align_session(samples, feedback=None)`` must be
byte-identical to the pre-feedback behavior — pinned first, below.
"""
from __future__ import annotations
import numpy as np
import pytest
from opensquilla.squilla_router.self_learning.alignment import (
REASON_CONFIDENCE_BACKOFF,
REASON_EXPLICIT_DOWNVOTE,
REASON_EXPLICIT_DOWNVOTE_ENSEMBLE,
REASON_EXPLICIT_DOWNVOTE_HIGH_TIER,
REASON_EXPLICIT_UPVOTE,
REASON_EXPLICIT_UPVOTE_ENSEMBLE,
REASON_IMMEDIATE_COMPLAINT,
REASON_NORMAL,
REASON_RETROSPECTIVE,
align_session,
)
from opensquilla.squilla_router.self_learning.feedback import FeedbackEntry
from opensquilla.squilla_router.self_learning.schema import (
RouterTrainSample,
encode_features,
)
SESSION = "agent:main:webchat:s1"
def _sample(
turn: int,
*,
route: str = "R0",
final: str | None = None,
complaint: bool = False,
gate: bool = False,
seed: float = 0.0,
) -> RouterTrainSample:
return RouterTrainSample(
session_key=SESSION,
turn_index=turn,
ts=f"2026-07-01T00:00:{turn:02d}Z",
feature_schema_version="v1",
features_390_b64=encode_features(np.full(390, seed, np.float32)),
route_class=route,
final_route_class=final or route,
complaint_detected=complaint,
confidence_gate_applied=gate,
decision_id=f"dec-{turn}",
)
def _fb(rating: str, kind: str = "single") -> FeedbackEntry:
return FeedbackEntry(rating=rating, executed_kind=kind)
def _fbmap(turn: int, rating: str, kind: str = "single") -> dict:
return {f"dec-{turn}": _fb(rating, kind)}
# --------------------------------------------------------------------------- #
# Regression anchor: feedback=None keeps the exact pre-feedback output
# --------------------------------------------------------------------------- #
def test_no_feedback_is_byte_identical_to_legacy() -> None:
samples = [
_sample(0, route="R0", seed=0.1),
_sample(1, route="R0", final="R2", complaint=True, seed=0.2),
_sample(2, route="R1", gate=True, seed=0.3),
_sample(3, route="R3", seed=0.4),
]
default = align_session(samples)
explicit_none = align_session(samples, None)
empty = align_session(samples, {})
for legacy, a, b in zip(default, explicit_none, empty, strict=True):
for other in (a, b):
assert legacy.reason == other.reason
assert legacy.target_idx == other.target_idx
assert legacy.confirmed == other.confirmed
assert np.array_equal(legacy.features_390, other.features_390)
# Reasons are exactly the legacy set on this fixture.
assert [s.reason for s in default] == [
REASON_RETROSPECTIVE, # turn 0: turn 1 complains, R0 eligible
REASON_IMMEDIATE_COMPLAINT,
REASON_CONFIDENCE_BACKOFF,
REASON_NORMAL,
]
# --------------------------------------------------------------------------- #
# Down-vote semantics (single-model)
# --------------------------------------------------------------------------- #
def test_downvote_on_underrouted_turn_upgrades_one_step() -> None:
samples = [_sample(0, route="R0"), _sample(1, route="R0")]
fb = _fbmap(0, "down")
aligned = align_session(samples, fb)
assert aligned[0].reason == REASON_EXPLICIT_DOWNVOTE
assert aligned[0].target_idx == 1 # +1 step only, not retro's +2
assert aligned[0].confirmed is True # calm follow-up confirms
def test_downvote_without_followup_is_unconfirmed() -> None:
samples = [_sample(0, route="R0")]
aligned = align_session(samples, _fbmap(0, "down"))
assert aligned[0].reason == REASON_EXPLICIT_DOWNVOTE
assert aligned[0].confirmed is False
def test_downvote_on_high_prediction_never_upgrades() -> None:
"""R2/R3 predictions: dissatisfaction is not attributable to the tier."""
samples = [_sample(0, route="R2"), _sample(1, route="R3")]
fb = {**_fbmap(0, "down"), **_fbmap(1, "down")}
aligned = align_session(samples, fb)
for a, expected_target in zip(aligned, (2, 3), strict=True):
assert a.reason == REASON_EXPLICIT_DOWNVOTE_HIGH_TIER # excluded bucket
assert a.target_idx == expected_target # target unchanged — no upgrade
def test_downvote_endorses_existing_complaint_upgrade() -> None:
samples = [_sample(0, route="R0", final="R2", complaint=True)]
aligned = align_session(samples, _fbmap(0, "down"))
assert aligned[0].reason == REASON_EXPLICIT_DOWNVOTE # upgraded from complaint
assert aligned[0].target_idx == 2 # complaint target kept
def test_downvote_on_capped_complaint_does_not_endorse_served_tier() -> None:
"""A complaint whose upgrade was capped (final == route) is not a real
upgrade; the down-vote must take the standalone +1 path, never train the
rejected tier at the table's highest weight."""
samples = [_sample(0, route="R0", final="R0", complaint=True)]
aligned = align_session(samples, _fbmap(0, "down"))
assert aligned[0].reason == REASON_EXPLICIT_DOWNVOTE
assert aligned[0].target_idx == 1 # +1 from served, NOT the served R0
def test_downvote_endorses_retrospective() -> None:
samples = [
_sample(0, route="R0"),
_sample(1, route="R0", final="R2", complaint=True),
_sample(2, route="R0"),
]
aligned = align_session(samples, _fbmap(0, "down"))
assert aligned[0].reason == REASON_EXPLICIT_DOWNVOTE
assert aligned[0].target_idx == 2 # retro target (resolving tier) kept
def test_next_turn_complaint_keeps_downvote_unconfirmed() -> None:
samples = [
_sample(0, route="R0"),
_sample(1, route="R0", final="R1", complaint=True),
_sample(2, route="R0"),
]
# Turn 0 down-voted AND turn 1 complains: retro fires first (target R1),
# the down-vote endorses it.
aligned = align_session(samples, _fbmap(0, "down"))
assert aligned[0].reason == REASON_EXPLICIT_DOWNVOTE
assert aligned[0].target_idx == 1
# --------------------------------------------------------------------------- #
# Ensemble split
# --------------------------------------------------------------------------- #
def test_ensemble_downvote_never_upgrades() -> None:
samples = [_sample(0, route="R0")]
aligned = align_session(samples, _fbmap(0, "down", "ensemble"))
assert aligned[0].reason == REASON_EXPLICIT_DOWNVOTE_ENSEMBLE
assert aligned[0].target_idx == 0 # no upgrade even though R0-eligible
def test_ensemble_upvote_uses_diluted_reason() -> None:
samples = [_sample(0, route="R1")]
aligned = align_session(samples, _fbmap(0, "up", "ensemble"))
assert aligned[0].reason == REASON_EXPLICIT_UPVOTE_ENSEMBLE
# --------------------------------------------------------------------------- #
# Up-vote semantics
# --------------------------------------------------------------------------- #
def test_upvote_confirms_served_tier() -> None:
samples = [_sample(0, route="R1")]
aligned = align_session(samples, _fbmap(0, "up"))
assert aligned[0].reason == REASON_EXPLICIT_UPVOTE
assert aligned[0].target_idx == 1
def test_upvote_never_overrides_corrections() -> None:
"""An up-vote must not weaken complaint/retro corrections on the turn."""
complaint = [_sample(0, route="R0", final="R2", complaint=True)]
aligned = align_session(complaint, _fbmap(0, "up"))
assert aligned[0].reason == REASON_IMMEDIATE_COMPLAINT
retro = [
_sample(0, route="R0"),
_sample(1, route="R0", final="R2", complaint=True),
_sample(2, route="R0"),
]
aligned2 = align_session(retro, _fbmap(0, "up"))
assert aligned2[0].reason == REASON_RETROSPECTIVE
def test_upvote_overrides_backoff() -> None:
samples = [_sample(0, route="R1", gate=True)]
aligned = align_session(samples, _fbmap(0, "up"))
assert aligned[0].reason == REASON_EXPLICIT_UPVOTE
# --------------------------------------------------------------------------- #
# Dataset weights
# --------------------------------------------------------------------------- #
def _weights_for(aligned_reasons_and_flags):
"""Build minimal AlignedSample list and run _compute_weights."""
from opensquilla.squilla_router.self_learning.alignment import AlignedSample
from opensquilla.squilla_router.self_learning.dataset import _compute_weights
aligned = []
for i, (reason, confirmed) in enumerate(aligned_reasons_and_flags):
aligned.append(
AlignedSample(
features_390=np.full(390, float(i), np.float32),
target_idx=1,
served_idx=1,
reason=reason,
session_key=SESSION,
turn_index=i,
day="2026-07-01",
feature_hash=f"h{i}", # distinct → no flood damping
confirmed=confirmed,
)
)
return _compute_weights(aligned)
def test_weight_table_and_exclusion() -> None:
weights = _weights_for(
[
(REASON_EXPLICIT_DOWNVOTE, True),
(REASON_RETROSPECTIVE, True),
(REASON_EXPLICIT_UPVOTE, True),
(REASON_EXPLICIT_UPVOTE_ENSEMBLE, True),
(REASON_EXPLICIT_DOWNVOTE_ENSEMBLE, True),
(REASON_EXPLICIT_DOWNVOTE_HIGH_TIER, True),
(REASON_NORMAL, True),
]
)
assert weights[0] == pytest.approx(1.2) # downvote > retro
assert weights[1] == pytest.approx(1.0)
assert weights[2] == pytest.approx(0.6)
assert weights[3] == pytest.approx(0.3) # ensemble upvote = normal level
assert weights[4] == 0.0 # excluded entirely
assert weights[5] == 0.0 # high-tier exclusion, distinct reason
assert weights[6] == pytest.approx(0.3)
def test_unconfirmed_downvote_halved() -> None:
weights = _weights_for([(REASON_EXPLICIT_DOWNVOTE, False)])
assert weights[0] == pytest.approx(0.6) # 1.2 * 0.5
def test_upvote_flood_damping() -> None:
"""Identical feature vectors: repeated up-votes are damped like normals."""
from opensquilla.squilla_router.self_learning.alignment import AlignedSample
from opensquilla.squilla_router.self_learning.dataset import _compute_weights
aligned = [
AlignedSample(
features_390=np.zeros(390, np.float32),
target_idx=1,
served_idx=1,
reason=REASON_EXPLICIT_UPVOTE,
session_key=SESSION,
turn_index=i,
day="2026-07-01",
feature_hash="same", # identical vector recurring
confirmed=True,
)
for i in range(4)
]
weights = _compute_weights(aligned)
assert weights[0] == pytest.approx(0.6 / 2.0) # 0.6 / sqrt(4)
# --------------------------------------------------------------------------- #
# Dataset join end-to-end (store -> feedback -> aligned matrix)
# --------------------------------------------------------------------------- #
def test_build_dataset_joins_feedback(tmp_path) -> None:
from opensquilla.squilla_router.self_learning.dataset import build_training_dataset
from opensquilla.squilla_router.self_learning.feedback import write_feedback
from opensquilla.squilla_router.self_learning.store import write_sample
for turn in range(3):
write_sample(_sample(turn, route="R0", seed=float(turn)), "main", home=tmp_path)
write_feedback(
"main",
decision_id="dec-0",
session_key=SESSION,
turn_index=0,
rating="down",
home=tmp_path,
)
ds = build_training_dataset("main", home=tmp_path)
reasons = dict(zip(ds.turn_indices, ds.reasons, strict=True))
assert reasons[0] == REASON_EXPLICIT_DOWNVOTE
assert reasons[1] == REASON_NORMAL
labels = dict(zip(ds.turn_indices, ds.y.tolist(), strict=True))
assert labels[0] == 1 # R0 -> R1 upgrade label
def test_build_dataset_without_feedback_unchanged(tmp_path) -> None:
"""No sidecar present: dataset identical to the pre-feedback pipeline."""
from opensquilla.squilla_router.self_learning.dataset import build_training_dataset
from opensquilla.squilla_router.self_learning.store import write_sample
for turn in range(3):
write_sample(_sample(turn, route="R0", seed=float(turn)), "main", home=tmp_path)
ds = build_training_dataset("main", home=tmp_path)
assert set(ds.reasons) == {REASON_NORMAL}
assert ds.y.tolist() == [0, 0, 0]
# --------------------------------------------------------------------------- #
# Rollback second trigger
# --------------------------------------------------------------------------- #
def test_downvote_rate_triggers_rollback() -> None:
from types import SimpleNamespace
from opensquilla.squilla_router.self_learning.promotion import should_rollback
cfg = SimpleNamespace()
# Complaint rate flat; downvote rate jumped 0.0 -> 0.4 on 6 ratings.
assert should_rollback(
pre_complaint_rate=0.1,
post_complaint_rate=0.1,
post_n=100,
config=cfg,
pre_downvote_rate=0.0,
post_downvote_rate=0.4,
post_feedback_n=6,
)
def test_downvote_trigger_respects_min_samples() -> None:
from types import SimpleNamespace
from opensquilla.squilla_router.self_learning.promotion import should_rollback
assert not should_rollback(
pre_complaint_rate=0.1,
post_complaint_rate=0.1,
post_n=100,
config=SimpleNamespace(),
pre_downvote_rate=0.0,
post_downvote_rate=1.0,
post_feedback_n=4, # below min_feedback_monitor_samples default 5
)
def test_complaint_trigger_unchanged() -> None:
from types import SimpleNamespace
from opensquilla.squilla_router.self_learning.promotion import should_rollback
assert should_rollback(
pre_complaint_rate=0.1,
post_complaint_rate=0.3,
post_n=30,
config=SimpleNamespace(),
)
assert not should_rollback(
pre_complaint_rate=0.1,
post_complaint_rate=0.12,
post_n=30,
config=SimpleNamespace(),
)
# --------------------------------------------------------------------------- #
# Volume gate counts down-votes
# --------------------------------------------------------------------------- #
def test_gate_counts_feedback_down_toward_volume() -> None:
from opensquilla.gateway.config import RouterSelfLearningConfig
from opensquilla.squilla_router.self_learning.gates import evaluate_training_gates
from opensquilla.squilla_router.self_learning.state import EventStoreStats, TrainState
cfg = RouterSelfLearningConfig(
enabled=True, train_min_samples=10, idle_hours=0.0, cooldown_hours=0.0
)
stats = EventStoreStats(
total=50, high_value=6, distinct_classes=2, last_ts="2026-01-01T00:00:00Z"
)
gate = evaluate_training_gates(config=cfg, state=TrainState(), stats=stats)
assert not gate.should_train # 6 < 10
stats_fb = EventStoreStats(
total=50,
high_value=6,
distinct_classes=2,
last_ts="2026-01-01T00:00:00Z",
feedback_down=4,
)
gate2 = evaluate_training_gates(config=cfg, state=TrainState(), stats=stats_fb)
assert gate2.should_train # 6 + 4 >= 10
# --------------------------------------------------------------------------- #
# Orchestrator end-to-end with feedback (promote baseline / rollback / receipt)
# --------------------------------------------------------------------------- #
def _write_min_store(tmp_path, n_high=8) -> None:
from opensquilla.squilla_router.self_learning.store import write_sample
for i in range(n_high):
s = RouterTrainSample(
session_key="agent:main:webchat:s1",
turn_index=i,
ts=f"2026-06-01T00:00:{i:02d}Z",
feature_schema_version="v1",
features_390_b64=encode_features(np.full(390, float(i), np.float32)),
route_class="R0" if i % 2 else "R1",
final_route_class="R2" if i % 2 else "R3",
complaint_detected=True,
)
write_sample(s, "main", home=tmp_path)
calm = RouterTrainSample(
session_key="agent:main:webchat:s2",
turn_index=0,
ts="2026-06-01T01:00:00Z",
feature_schema_version="v1",
features_390_b64=encode_features(np.zeros(390, np.float32)),
route_class="R1",
final_route_class="R1",
)
write_sample(calm, "main", home=tmp_path)
def test_orchestrator_records_and_clears_downvote_baseline(tmp_path) -> None:
from datetime import UTC, datetime
from opensquilla.gateway.config import RouterSelfLearningConfig, SquillaRouterConfig
from opensquilla.squilla_router.self_learning.feedback import write_feedback
from opensquilla.squilla_router.self_learning.orchestrator import (
in_process_trainer,
maybe_run_update_router,
)
from opensquilla.squilla_router.self_learning.state import load_train_state
now = datetime(2026, 6, 6, 12, 0, 0, tzinfo=UTC)
_write_min_store(tmp_path)
# Pre-promotion feedback: 1 down / 4 up (single) -> baseline 0.2.
for i, rating in enumerate(["down", "up", "up", "up", "up"]):
write_feedback(
"main",
decision_id=f"pre-{i}",
session_key="agent:main:webchat:s1",
turn_index=i,
rating=rating,
home=tmp_path,
now=datetime(2026, 6, 2, 0, 0, i, tzinfo=UTC),
)
cfg = SquillaRouterConfig(
self_learning=RouterSelfLearningConfig(
enabled=True,
train_min_samples=4,
idle_hours=2.0,
cooldown_hours=72.0,
holdout_min_size=4,
holdout_pct=0.4,
holdout_repeats=2,
max_critical_under_routing=1.0,
cost_tolerance_pct=500.0,
)
)
(tmp_path / "base").mkdir() # empty base -> trains fresh
res = maybe_run_update_router(
"main",
router_cfg=cfg,
home=tmp_path,
now=now,
trainer=in_process_trainer,
base_dir=tmp_path / "base",
)
assert res.promoted, res
state = load_train_state("main", tmp_path)
assert state.pre_promotion_downvote_rate == pytest.approx(0.2)
import json as _json
receipts = list((tmp_path / "router" / ".receipts").glob("main-*-promoted.json"))
assert receipts
payload = _json.loads(receipts[0].read_text())
assert payload["pre_promotion_downvote_rate"] == pytest.approx(0.2)
def test_orchestrator_no_feedback_baseline_is_none(tmp_path) -> None:
"""Zero recorded ratings -> unmeasured baseline (None), never 0.0."""
from datetime import UTC, datetime
from opensquilla.gateway.config import RouterSelfLearningConfig, SquillaRouterConfig
from opensquilla.squilla_router.self_learning.orchestrator import (
in_process_trainer,
maybe_run_update_router,
)
from opensquilla.squilla_router.self_learning.state import load_train_state
_write_min_store(tmp_path)
cfg = SquillaRouterConfig(
self_learning=RouterSelfLearningConfig(
enabled=True,
train_min_samples=4,
idle_hours=2.0,
cooldown_hours=72.0,
holdout_min_size=4,
holdout_pct=0.4,
holdout_repeats=2,
max_critical_under_routing=1.0,
cost_tolerance_pct=500.0,
)
)
(tmp_path / "base").mkdir() # empty base -> trains fresh
res = maybe_run_update_router(
"main",
router_cfg=cfg,
home=tmp_path,
now=datetime(2026, 6, 6, 12, 0, 0, tzinfo=UTC),
trainer=in_process_trainer,
base_dir=tmp_path / "base",
)
assert res.promoted, res
state = load_train_state("main", tmp_path)
assert state.pre_promotion_downvote_rate is None
# And with a None baseline the feedback trigger cannot fire even on a
# burst of early downvotes (should_rollback skips it).
from types import SimpleNamespace
from opensquilla.squilla_router.self_learning.promotion import should_rollback
assert not should_rollback(
pre_complaint_rate=0.5, # flat
post_complaint_rate=0.5,
post_n=100,
config=SimpleNamespace(),
pre_downvote_rate=None,
post_downvote_rate=1.0,
post_feedback_n=50,
)
def test_ensemble_downvotes_do_not_open_volume_gate(tmp_path) -> None:
"""Design invariant: gate counts only label-producing (single) downvotes."""
from opensquilla.squilla_router.self_learning.feedback import write_feedback
from opensquilla.squilla_router.self_learning.orchestrator import _with_feedback_stats
from opensquilla.squilla_router.self_learning.state import EventStoreStats
for i in range(6):
write_feedback(
"main",
decision_id=f"e-{i}",
session_key="agent:main:webchat:s1",
turn_index=i,
rating="down",
executed_kind="ensemble",
home=tmp_path,
)
stats = _with_feedback_stats(EventStoreStats(total=10), "main", tmp_path)
assert stats.feedback_down == 0 # ensemble downvotes produce no labels
write_feedback(
"main",
decision_id="s-1",
session_key="agent:main:webchat:s1",
turn_index=9,
rating="down",
executed_kind="single",
home=tmp_path,
)
stats2 = _with_feedback_stats(EventStoreStats(total=10), "main", tmp_path)
assert stats2.feedback_down == 1
def test_downvote_anchor_is_served_aware() -> None:
"""Gate/hold turns: the bump starts from the SERVED tier, not the raw
prediction, so a downvote never trains the tier the user just rejected."""
# Confidence gate: predicted R0, served R1 (gate default). Downvote must
# target R2, not R1.
gated = _sample(0, route="R0", final="R1", gate=True)
aligned = align_session([gated], _fbmap(0, "down"))
assert aligned[0].reason == REASON_EXPLICIT_DOWNVOTE
assert aligned[0].target_idx == 2
# Anti-downgrade hold: predicted R0, served R2. Served index is above the
# eligibility cap -> excluded, no upgrade label pointing below the served.
held = _sample(0, route="R0", final="R2")
aligned2 = align_session([held], _fbmap(0, "down"))
assert aligned2[0].reason == REASON_EXPLICIT_DOWNVOTE_HIGH_TIER
assert aligned2[0].target_idx == 2