"""
app/relabel_policy.py
-----------------------------------------------------------------------------
Server-side policy table and score-to-label mapping for the Axis Descriptor Lab.
This module owns the lab's score-to-label mapping and the canonical axis
ordering used by the standalone UI. The definitions here are intentionally
aligned with the current Pipe-Works mud-server policy files:
- ``pipeworks_mud_server/data/worlds/pipeworks_web/policies/axes.yaml``
- ``pipeworks_mud_server/data/worlds/pipeworks_web/policies/thresholds.yaml``
The mud server remains the runtime authority. Axis Lab keeps a checked-in
lab-side copy of the current rules so the UI can perform deterministic local
inspection and relabelling without keeping the older mirror-heavy file model
alive.
Exports
-------
AXIS_ORDER : list[str]
Canonical full-axis ordering, matching the mud-server ``axes.yaml`` key
order for the bundled worlds.
AXIS_LABEL_ORDER : dict[str, list[str]]
Canonical low-to-high ordinal label sequence for each axis, matching the
mud-server ``ordering.values`` payloads.
RELABEL_POLICY : dict[str, list[tuple[float, float, str]]]
Module-level constant mapping each known axis name to an ordered list of
inclusive ``(min_score, max_score, label)`` tuples derived from the
mud-server ``thresholds.yaml`` ranges.
apply_relabel_policy(payload) -> AxisPayload
Walk the payload's axes, recompute labels from ``RELABEL_POLICY`` for
known axes, and return a new ``AxisPayload`` with updated labels.
Unknown axes are passed through unchanged. Scores are never modified.
Design notes
------------
The policy table lives in its own module (rather than inline in a route
handler) so that:
1. Unit tests can validate the current mud-server-aligned policy in isolation
without hitting the HTTP layer.
2. The table can be imported by other modules (e.g. future CLI tools)
without pulling in all of FastAPI.
3. ``main.py`` stays a thin routing layer — it calls
``apply_relabel_policy(payload)`` and returns the result.
"""
from __future__ import annotations
from app.schema import AxisPayload
# -----------------------------------------------------------------------------
# Canonical axis ordering
# -----------------------------------------------------------------------------
#
# ``AXIS_ORDER`` mirrors the current mud-server ``axes.yaml`` order. The
# frontend uses it to render rows deterministically instead of depending on
# incoming JSON insertion order.
AXIS_ORDER: list[str] = [
"physique",
"wealth",
"health",
"demeanor",
"age",
"facial_signal",
"legitimacy",
"visibility",
"moral_load",
"dependency",
"risk_exposure",
]
# Each label list is low → high, matching the mud-server ``ordering.values``
# payload for that axis.
AXIS_LABEL_ORDER: dict[str, list[str]] = {
"physique": ["frail", "hunched", "skinny", "wiry", "broad", "stocky"],
"wealth": ["poor", "modest", "well-kept", "wealthy", "decadent"],
"health": ["sickly", "limping", "weary", "scarred", "hale"],
"demeanor": ["timid", "suspicious", "resentful", "alert", "proud"],
"age": ["young", "middle-aged", "old", "ancient"],
"facial_signal": [
"understated",
"pronounced",
"exaggerated",
"asymmetrical",
"weathered",
"soft-featured",
"sharp-featured",
],
"legitimacy": ["sanctioned", "tolerated", "questioned", "illicit"],
"visibility": ["hidden", "discrete", "routine", "conspicuous"],
"moral_load": ["neutral", "burdened", "conflicted", "corrosive"],
"dependency": ["optional", "useful", "necessary", "unavoidable"],
"risk_exposure": ["benign", "straining", "hazardous", "eroding"],
}
# -----------------------------------------------------------------------------
# Policy table
# -----------------------------------------------------------------------------
#
# Structure: axis_name -> ordered list of inclusive (min_score, max_score, label)
#
# These ranges track the mud-server ``thresholds.yaml`` values exactly. The
# server currently resolves labels with inclusive ``min``/``max`` checks, so
# the lab uses the same contract here rather than the previous simplified
# ``score < upper_bound`` approximation.
type PolicyRange = tuple[float, float, str]
RELABEL_POLICY: dict[str, list[PolicyRange]] = {
"physique": [
(0.00, 0.16, "frail"),
(0.17, 0.32, "hunched"),
(0.33, 0.48, "skinny"),
(0.49, 0.64, "wiry"),
(0.65, 0.80, "broad"),
(0.81, 1.00, "stocky"),
],
"wealth": [
(0.00, 0.19, "poor"),
(0.20, 0.39, "modest"),
(0.40, 0.59, "well-kept"),
(0.60, 0.79, "wealthy"),
(0.80, 1.00, "decadent"),
],
"health": [
(0.00, 0.19, "sickly"),
(0.20, 0.39, "limping"),
(0.40, 0.59, "weary"),
(0.60, 0.79, "scarred"),
(0.80, 1.00, "hale"),
],
"demeanor": [
(0.00, 0.19, "timid"),
(0.20, 0.39, "suspicious"),
(0.40, 0.59, "resentful"),
(0.60, 0.79, "alert"),
(0.80, 1.00, "proud"),
],
"age": [
(0.00, 0.24, "young"),
(0.25, 0.49, "middle-aged"),
(0.50, 0.74, "old"),
(0.75, 1.00, "ancient"),
],
"facial_signal": [
(0.00, 0.14, "understated"),
(0.15, 0.29, "pronounced"),
(0.30, 0.44, "exaggerated"),
(0.45, 0.59, "asymmetrical"),
(0.60, 0.74, "weathered"),
(0.75, 0.89, "soft-featured"),
(0.90, 1.00, "sharp-featured"),
],
"legitimacy": [
(0.00, 0.24, "sanctioned"),
(0.25, 0.49, "tolerated"),
(0.50, 0.74, "questioned"),
(0.75, 1.00, "illicit"),
],
"visibility": [
(0.00, 0.24, "hidden"),
(0.25, 0.49, "discrete"),
(0.50, 0.74, "routine"),
(0.75, 1.00, "conspicuous"),
],
"moral_load": [
(0.00, 0.24, "neutral"),
(0.25, 0.49, "burdened"),
(0.50, 0.74, "conflicted"),
(0.75, 1.00, "corrosive"),
],
"dependency": [
(0.00, 0.24, "optional"),
(0.25, 0.49, "useful"),
(0.50, 0.74, "necessary"),
(0.75, 1.00, "unavoidable"),
],
"risk_exposure": [
(0.00, 0.24, "benign"),
(0.25, 0.49, "straining"),
(0.50, 0.74, "hazardous"),
(0.75, 1.00, "eroding"),
],
}
# -----------------------------------------------------------------------------
# Public API
# -----------------------------------------------------------------------------
[docs]
def resolve_axis_label(axis_name: str, score: float, fallback_label: str) -> str:
"""
Resolve ``score`` to the mud-server-aligned label for ``axis_name``.
The lookup uses inclusive ``min <= score <= max`` range checks to mirror
the mud server's axis-value resolution logic. If the axis is unknown, or
if the score falls outside every configured range, ``fallback_label`` is
returned unchanged.
Returning the existing label for unmatched scores is intentional: the lab's
schema requires a non-empty label string, while the mud server stores label
absence as ``None`` in the database layer.
Parameters
----------
axis_name : str
Axis key to resolve.
score : float
Normalised axis score in ``[0.0, 1.0]``.
fallback_label : str
Existing label to preserve when no configured range matches.
Returns
-------
str
The resolved canonical label, or ``fallback_label`` when the axis or
score is not covered by the configured policy.
"""
for min_score, max_score, label in RELABEL_POLICY.get(axis_name, []):
if min_score <= score <= max_score:
return label
return fallback_label
[docs]
def apply_relabel_policy(payload: AxisPayload) -> AxisPayload:
"""
Recompute axis labels from the policy table and return an updated payload.
For each axis in *payload*, if the axis name appears in
:data:`RELABEL_POLICY`, the label is rewritten to the matching mud-server
threshold range. Unknown axes (those not in the policy table)
are passed through with their existing labels intact.
Scores are **never** modified — only labels change. All non-axis fields
(``policy_hash``, ``seed``, ``world_id``) are preserved verbatim.
Parameters
----------
payload : AxisPayload
The current axis payload with scores and (possibly stale) labels.
Returns
-------
AxisPayload
A new payload instance with labels recomputed from scores.
"""
updated_axes = {}
for axis_name, axis_val in payload.axes.items():
if axis_name in RELABEL_POLICY:
new_label = resolve_axis_label(
axis_name=axis_name,
score=axis_val.score,
fallback_label=axis_val.label,
)
# Create a new AxisValue with the updated label, same score.
updated_axes[axis_name] = axis_val.model_copy(update={"label": new_label})
else:
# Unknown axis — pass through unchanged.
updated_axes[axis_name] = axis_val
# Return a new payload with updated axes; all other fields unchanged.
return payload.model_copy(update={"axes": updated_axes})