628 lines
22 KiB
Python
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
|