Source code for build_tools.name_selector.policy

"""
Policy evaluation logic for name candidates.

This module contains the core evaluation function that scores a name
candidate against a name class policy. It implements the ✓/~/✗ scoring
model defined in the Name Class Matrix.

Scoring Model
-------------
- **Preferred (✓)**: Feature present → +1 score
- **Tolerated (~)**: Feature present → 0 score (neutral)
- **Discouraged (✗)**: Feature present → Reject (hard) or -10 (soft)

The evaluation considers only features that are TRUE in the candidate.
Features that are FALSE do not contribute to the score (absence is neutral).

Evaluation Modes
----------------
**Hard Mode** (default):
    Any discouraged feature present causes immediate rejection.
    The candidate is not scored further.

**Soft Mode**:
    Discouraged features apply a -10 penalty instead of rejection.
    Useful for exploring edge cases or when flexibility is needed.

Usage
-----
>>> from build_tools.name_selector.policy import evaluate_candidate
>>> from build_tools.name_selector.name_class import NameClassPolicy
>>>
>>> policy = NameClassPolicy(
...     name="first_name",
...     description="Test",
...     syllable_range=(2, 3),
...     features={"ends_with_vowel": "preferred", "ends_with_stop": "discouraged"},
... )
>>> candidate = {
...     "name": "kali",
...     "features": {"ends_with_vowel": True, "ends_with_stop": False},
... }
>>> admitted, score, details = evaluate_candidate(candidate, policy, mode="hard")
>>> admitted
True
>>> score
1
>>> details["preferred_hits"]
['ends_with_vowel']
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Literal

if TYPE_CHECKING:
    from build_tools.name_selector.name_class import NameClassPolicy

# Scoring constants
PREFERRED_SCORE = 1
TOLERATED_SCORE = 0
DISCOURAGED_PENALTY = -10  # Used in soft mode


[docs] def evaluate_candidate( candidate: dict, policy: NameClassPolicy, mode: Literal["hard", "soft"] = "hard", ) -> tuple[bool, int, dict]: """ Evaluate a name candidate against a name class policy. Scores the candidate based on which of its TRUE features match preferred, tolerated, or discouraged designations in the policy. Parameters ---------- candidate : dict Candidate dictionary with "name", "features", and optionally "syllables". Features must be a dict[str, bool]. policy : NameClassPolicy The policy to evaluate against. mode : {"hard", "soft"}, optional Evaluation mode. "hard" rejects on any discouraged feature. "soft" applies a -10 penalty instead. Default: "hard". Returns ------- tuple[bool, int, dict] - admitted: True if candidate passes policy, False if rejected - score: Numeric score (higher is better) - details: Evaluation details for debugging Details dict structure: - preferred_hits: list[str] - Preferred features that are TRUE - tolerated_hits: list[str] - Tolerated features that are TRUE - discouraged_hits: list[str] - Discouraged features that are TRUE - rejection_reason: str | None - Reason for rejection (if any) Examples -------- >>> # Candidate with preferred feature >>> candidate = {"name": "kali", "features": {"ends_with_vowel": True}} >>> admitted, score, details = evaluate_candidate(candidate, policy) >>> admitted, score (True, 1) >>> # Candidate with discouraged feature (hard mode) >>> candidate = {"name": "kalt", "features": {"ends_with_stop": True}} >>> admitted, score, details = evaluate_candidate(candidate, policy, mode="hard") >>> admitted False >>> details["rejection_reason"] 'ends_with_stop' Notes ----- Only TRUE features are evaluated. If a feature is FALSE in the candidate, it does not contribute to the score regardless of its policy designation. This means "discouraged" means "discouraged when present", not "required to be absent". """ features = candidate.get("features", {}) preferred_hits: list[str] = [] tolerated_hits: list[str] = [] discouraged_hits: list[str] = [] score = 0 # Evaluate each feature that is TRUE in the candidate for feature_name, is_present in features.items(): if not is_present: # Feature is FALSE - does not contribute continue # Get policy for this feature (default to tolerated if not specified) policy_value = policy.features.get(feature_name, "tolerated") if policy_value == "preferred": preferred_hits.append(feature_name) score += PREFERRED_SCORE elif policy_value == "tolerated": tolerated_hits.append(feature_name) score += TOLERATED_SCORE elif policy_value == "discouraged": discouraged_hits.append(feature_name) if mode == "hard": # Hard mode: immediate rejection return ( False, score, { "preferred_hits": preferred_hits, "tolerated_hits": tolerated_hits, "discouraged_hits": discouraged_hits, "rejection_reason": feature_name, }, ) else: # Soft mode: apply penalty score += DISCOURAGED_PENALTY # Build details details = { "preferred_hits": preferred_hits, "tolerated_hits": tolerated_hits, "discouraged_hits": discouraged_hits, "rejection_reason": None, } # In soft mode, check if cumulative penalties make this unviable # (optional: could add a threshold here) return (True, score, details)
[docs] def check_syllable_count( candidate: dict, policy: NameClassPolicy, ) -> bool: """ Check if a candidate's syllable count is within policy range. Parameters ---------- candidate : dict Candidate dictionary with "syllables" key (list of syllable strings). policy : NameClassPolicy The policy with syllable_range constraint. Returns ------- bool True if syllable count is within range, False otherwise. Examples -------- >>> policy = NameClassPolicy(..., syllable_range=(2, 3)) >>> check_syllable_count({"syllables": ["ka", "li"]}, policy) True >>> check_syllable_count({"syllables": ["ka"]}, policy) False """ syllables = candidate.get("syllables", []) count = len(syllables) min_syl, max_syl = policy.syllable_range return min_syl <= count <= max_syl