Skip to content

core.inference_policy

Versioned inference-policy artifact: model + calibration + reject threshold.

Overview

The InferencePolicy is the canonical deployment descriptor for inference. It binds a specific model bundle to an optional calibrator store and a reject threshold that was tuned on the (potentially calibrated) score distribution.

model bundle + calibrator store + reject threshold → InferencePolicy
                                          inference_policy.json
                                         resolve_inference_config()
                                    OnlinePredictor / batch / tray

The policy file lives at models/inference_policy.json and is the first thing inference resolution checks.

Canonical template (repository)

Unlike per-user config.toml, models/inference_policy.json is not auto-written on first tray startup. The repository holds a stable checked-in example at configs/inference_policy.template.json that matches render_default_inference_policy_template_json() (fixed created_at / git_commit for reproducibility). See the Inference policy template guide.

The module also exposes a low-level write_inference_policy_starter_template() helper for code paths that explicitly want a placeholder file with live provenance and _help.paths_are_relative_to set to your install’s TASKCLF_HOME.

Editing from the tray

The tray menu Advanced → Edit Inference Policy opens this file in the default editor. If the file is missing, the tray creates it first only when a model bundle can be resolved. In that case the seeded policy reuses the bundle's metadata.json advisory reject_threshold and attaches a matching calibrator store when artifacts/**/store.json explicitly binds to the same model/schema. When no model can be resolved, the tray does not write a placeholder file; it notifies the user to use Prediction Model or Open Data Folder first, and includes an optional CLI hint (taskclf policy create --model-dir models/<run_id>) for environments where the CLI is installed. Prefer taskclf policy create for a validated policy. Hand-edited JSON that fails validation is ignored by load_inference_policy, so inference falls back to active.json resolution until the file is fixed.

Resolution precedence

When inference starts:

  1. Explicit --model-dir CLI override — bypasses policy entirely.
  2. models/inference_policy.json — canonical deployment descriptor.
  3. models/active.json + code defaults — deprecated legacy fallback.
  4. Best-model selection + code defaults — no-config fallback.

InferencePolicy fields

Field Type Description
policy_version Literal["v1"] Schema version of the policy
model_dir str Path to model bundle, relative to models_dir.parent
model_schema_hash str Must match the bundle's metadata.schema_hash
model_label_set list[str] Must match the bundle's metadata.label_set
calibrator_store_dir str \| None Path to calibrator store, relative to models_dir.parent
calibration_method str \| None "temperature" or "isotonic"
reject_threshold float Tuned on calibrated scores when calibrator is present
created_at str ISO-8601 timestamp
source str "manual", "tune-reject", "retrain", "calibrate", "tray-edit", "starter-template", etc.
git_commit str Git commit SHA at creation time

Functions

build_inference_policy

build_inference_policy(
    *,
    model_dir: str,
    model_schema_hash: str,
    model_label_set: list[str],
    reject_threshold: float = DEFAULT_REJECT_THRESHOLD,
    calibrator_store_dir: str | None = None,
    calibration_method: str | None = None,
    source: str = "manual",
) -> InferencePolicy

Convenience builder that fills in created_at and git_commit automatically.

save_inference_policy

save_inference_policy(policy: InferencePolicy, models_dir: Path) -> Path

Atomically persist the policy as models_dir/inference_policy.json. Uses temp-file + os.replace so readers never see a partial write.

load_inference_policy

load_inference_policy(models_dir: Path) -> InferencePolicy | None

Read the policy file. Returns None on missing, invalid JSON, or validation failure (no exception raised).

remove_inference_policy

remove_inference_policy(models_dir: Path) -> bool

Delete the policy file. Returns True if removed, False if it did not exist.

render_default_inference_policy_template_json

render_default_inference_policy_template_json() -> str

Return the canonical JSON text for configs/inference_policy.template.json (stable timestamps for the checked-in file).

write_inference_policy_starter_template

write_inference_policy_starter_template(models_dir: Path) -> Path

Atomically write a placeholder inference_policy.json for manual editing (same shape as the canonical template, with live created_at / git_commit and _help.paths_are_relative_to).

validate_policy

validate_policy(policy: InferencePolicy, models_dir: Path) -> None

Validate that the policy's references resolve to compatible artifacts on disk. Raises PolicyValidationError on failure.

Checks:

  1. model_dir exists with metadata.json.
  2. model_schema_hash matches the bundle.
  3. model_label_set matches the bundle.
  4. calibrator_store_dir (if set) exists with store.json.
  5. Calibrator store's model_schema_hash (if set) matches the policy.

Usage

After tuning

taskclf train tune-reject \
  --model-dir models/run_001 \
  --calibrator-store artifacts/calibrator_store \
  --from 2026-01-01 --to 2026-01-31 \
  --write-policy

Manual creation

taskclf policy create \
  --model-dir models/run_001 \
  --calibrator-store artifacts/calibrator_store \
  --reject-threshold 0.55

Inspect / remove

taskclf policy show
taskclf policy remove

taskclf.core.inference_policy

Versioned inference-policy artifact: model + calibration + reject threshold.

The :class:InferencePolicy is the canonical deployment descriptor. It binds a specific model bundle to an optional calibrator store and a reject threshold that was tuned on the (potentially calibrated) score distribution.

Persistence uses the same atomic-write pattern as :func:~taskclf.model_registry.write_active_atomic: write to a temporary file, then :func:os.replace so readers never see a partial write.

InferencePolicy

Bases: BaseModel

Versioned deployment descriptor binding model + calibration + threshold.

Stored as models/inference_policy.json. Inference resolution reads this file to determine which model bundle, calibrator store, and reject threshold to use.

All paths are relative to models_dir.parent (i.e. relative to TASKCLF_HOME), matching the convention used by :class:~taskclf.model_registry.ActivePointer.

Source code in src/taskclf/core/inference_policy.py
class InferencePolicy(BaseModel, frozen=True):
    """Versioned deployment descriptor binding model + calibration + threshold.

    Stored as ``models/inference_policy.json``.  Inference resolution
    reads this file to determine which model bundle, calibrator store,
    and reject threshold to use.

    All paths are **relative to ``models_dir.parent``** (i.e. relative
    to ``TASKCLF_HOME``), matching the convention used by
    :class:`~taskclf.model_registry.ActivePointer`.
    """

    policy_version: Literal["v1"] = "v1"

    # ── Model binding ──
    model_dir: str
    model_schema_hash: str
    model_label_set: list[str]

    # ── Calibration binding (None = identity / no calibration) ──
    calibrator_store_dir: str | None = None
    calibration_method: str | None = None

    # ── Reject threshold (tuned on calibrated scores when calibrator is present) ──
    reject_threshold: float
    per_user_reject_thresholds: dict[str, float] | None = None

    # ── Provenance ──
    created_at: str = Field(
        default_factory=lambda: datetime.now(UTC).isoformat(),
    )
    source: str = "manual"
    git_commit: str = ""

PolicyValidationError

Bases: Exception

Raised when an inference policy fails validation against disk artifacts.

Source code in src/taskclf/core/inference_policy.py
class PolicyValidationError(Exception):
    """Raised when an inference policy fails validation against disk artifacts."""

    def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
        super().__init__(message)
        self.details = details or {}

build_inference_policy(*, model_dir, model_schema_hash, model_label_set, reject_threshold=DEFAULT_REJECT_THRESHOLD, calibrator_store_dir=None, calibration_method=None, source='manual')

Convenience builder that fills in provenance fields automatically.

Parameters:

Name Type Description Default
model_dir str

Path to model bundle, relative to models_dir.parent.

required
model_schema_hash str

Schema hash from the model's metadata.

required
model_label_set list[str]

Label set from the model's metadata.

required
reject_threshold float

Reject threshold for this model+calibration pair.

DEFAULT_REJECT_THRESHOLD
calibrator_store_dir str | None

Path to calibrator store directory, relative to models_dir.parent. None for identity.

None
calibration_method str | None

"temperature" or "isotonic"; None when no calibrator store is used.

None
source str

How this policy was created ("manual", "tune-reject", "retrain", "calibrate").

'manual'

Returns:

Type Description
InferencePolicy

A populated :class:InferencePolicy.

Source code in src/taskclf/core/inference_policy.py
def build_inference_policy(
    *,
    model_dir: str,
    model_schema_hash: str,
    model_label_set: list[str],
    reject_threshold: float = DEFAULT_REJECT_THRESHOLD,
    calibrator_store_dir: str | None = None,
    calibration_method: str | None = None,
    source: str = "manual",
) -> InferencePolicy:
    """Convenience builder that fills in provenance fields automatically.

    Args:
        model_dir: Path to model bundle, relative to ``models_dir.parent``.
        model_schema_hash: Schema hash from the model's metadata.
        model_label_set: Label set from the model's metadata.
        reject_threshold: Reject threshold for this model+calibration pair.
        calibrator_store_dir: Path to calibrator store directory,
            relative to ``models_dir.parent``.  ``None`` for identity.
        calibration_method: ``"temperature"`` or ``"isotonic"``; ``None``
            when no calibrator store is used.
        source: How this policy was created (``"manual"``,
            ``"tune-reject"``, ``"retrain"``, ``"calibrate"``).

    Returns:
        A populated :class:`InferencePolicy`.
    """
    return InferencePolicy(
        model_dir=model_dir,
        model_schema_hash=model_schema_hash,
        model_label_set=sorted(model_label_set),
        reject_threshold=reject_threshold,
        calibrator_store_dir=calibrator_store_dir,
        calibration_method=calibration_method,
        source=source,
        git_commit=_current_git_commit(),
    )

render_default_inference_policy_template_json()

Return the canonical checked-in starter JSON text for configs/.

Uses stable created_at / git_commit so the file matches the repository template byte-for-byte. Runtime starter files written by :func:write_inference_policy_starter_template use live provenance.

Source code in src/taskclf/core/inference_policy.py
def render_default_inference_policy_template_json() -> str:
    """Return the canonical checked-in starter JSON text for ``configs/``.

    Uses stable ``created_at`` / ``git_commit`` so the file matches the
    repository template byte-for-byte. Runtime starter files written by
    :func:`write_inference_policy_starter_template` use live provenance.
    """
    policy = InferencePolicy(
        model_dir="models/<run_id>",
        model_schema_hash="<schema_hash>",
        model_label_set=["<label>"],
        reject_threshold=DEFAULT_REJECT_THRESHOLD,
        created_at=_INFERENCE_POLICY_TEMPLATE_CANONICAL_CREATED_AT,
        git_commit="",
        source="template",
    )
    data = policy.model_dump()
    data["_help"] = _inference_policy_starter_help(
        _INFERENCE_POLICY_TEMPLATE_PATHS_PLACEHOLDER
    )
    return json.dumps(data, indent=2, ensure_ascii=False) + "\n"

write_inference_policy_starter_template(models_dir)

Atomically write a placeholder inference_policy.json for manual editing.

Same content shape as :func:render_default_inference_policy_template_json, but with live created_at / git_commit and _help.paths_are_relative_to set to str(models_dir.parent).

Parameters:

Name Type Description Default
models_dir Path

The models/ directory (under TASKCLF_HOME).

required

Returns:

Type Description
Path

Path to the written policy file.

Source code in src/taskclf/core/inference_policy.py
def write_inference_policy_starter_template(models_dir: Path) -> Path:
    """Atomically write a placeholder ``inference_policy.json`` for manual editing.

    Same content shape as :func:`render_default_inference_policy_template_json`,
    but with live ``created_at`` / ``git_commit`` and
    ``_help.paths_are_relative_to`` set to ``str(models_dir.parent)``.

    Args:
        models_dir: The ``models/`` directory (under TASKCLF_HOME).

    Returns:
        Path to the written policy file.
    """
    policy = build_inference_policy(
        model_dir="models/<run_id>",
        model_schema_hash="<schema_hash>",
        model_label_set=["<label>"],
        reject_threshold=DEFAULT_REJECT_THRESHOLD,
        source="starter-template",
    )
    data = policy.model_dump()
    data["_help"] = _inference_policy_starter_help(str(models_dir.parent))

    models_dir.mkdir(parents=True, exist_ok=True)
    tmp_path = models_dir / _POLICY_STARTER_TMP
    final_path = models_dir / DEFAULT_INFERENCE_POLICY_FILE
    tmp_path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
    os.replace(tmp_path, final_path)
    logger.info("Wrote inference policy starter template to %s", final_path)
    return final_path

save_inference_policy(policy, models_dir)

Atomically persist policy as models_dir/inference_policy.json.

Uses temp-file + :func:os.replace so readers never see a partial write.

Parameters:

Name Type Description Default
policy InferencePolicy

The policy to persist.

required
models_dir Path

The models/ directory.

required

Returns:

Type Description
Path

Path to the written policy file.

Source code in src/taskclf/core/inference_policy.py
def save_inference_policy(
    policy: InferencePolicy,
    models_dir: Path,
) -> Path:
    """Atomically persist *policy* as ``models_dir/inference_policy.json``.

    Uses temp-file + :func:`os.replace` so readers never see a partial
    write.

    Args:
        policy: The policy to persist.
        models_dir: The ``models/`` directory.

    Returns:
        Path to the written policy file.
    """
    models_dir.mkdir(parents=True, exist_ok=True)

    tmp_path = models_dir / _POLICY_TMP
    final_path = models_dir / DEFAULT_INFERENCE_POLICY_FILE

    tmp_path.write_text(policy.model_dump_json(indent=2) + "\n")
    os.replace(tmp_path, final_path)

    logger.info(
        "Wrote inference policy to %s (model=%s, threshold=%.4f, source=%s)",
        final_path,
        policy.model_dir,
        policy.reject_threshold,
        policy.source,
    )
    return final_path

load_inference_policy(models_dir)

Load the inference policy from models_dir/inference_policy.json.

Returns None (without raising) when the file is missing, contains invalid JSON, or fails validation.

Parameters:

Name Type Description Default
models_dir Path

The models/ directory.

required

Returns:

Type Description
InferencePolicy | None

A validated :class:InferencePolicy, or None.

Source code in src/taskclf/core/inference_policy.py
def load_inference_policy(models_dir: Path) -> InferencePolicy | None:
    """Load the inference policy from ``models_dir/inference_policy.json``.

    Returns ``None`` (without raising) when the file is missing, contains
    invalid JSON, or fails validation.

    Args:
        models_dir: The ``models/`` directory.

    Returns:
        A validated :class:`InferencePolicy`, or ``None``.
    """
    policy_path = models_dir / DEFAULT_INFERENCE_POLICY_FILE
    if not policy_path.is_file():
        return None

    try:
        raw = json.loads(policy_path.read_text())
        return InferencePolicy.model_validate(raw)
    except (json.JSONDecodeError, ValueError, OSError) as exc:
        logger.warning(
            "Could not load inference policy from %s: %s",
            policy_path,
            exc,
        )
        return None

remove_inference_policy(models_dir)

Delete models_dir/inference_policy.json if it exists.

Parameters:

Name Type Description Default
models_dir Path

The models/ directory.

required

Returns:

Type Description
bool

True if the file was removed, False if it did not exist.

Source code in src/taskclf/core/inference_policy.py
def remove_inference_policy(models_dir: Path) -> bool:
    """Delete ``models_dir/inference_policy.json`` if it exists.

    Args:
        models_dir: The ``models/`` directory.

    Returns:
        ``True`` if the file was removed, ``False`` if it did not exist.
    """
    policy_path = models_dir / DEFAULT_INFERENCE_POLICY_FILE
    try:
        policy_path.unlink()
        logger.info("Removed inference policy at %s", policy_path)
        return True
    except FileNotFoundError:
        return False

validate_policy(policy, models_dir)

Validate that policy references artifacts that exist and are compatible.

Checks:

  1. model_dir resolves to an existing directory with metadata.json.
  2. model_schema_hash matches the bundle's recorded schema hash.
  3. model_label_set matches the bundle's recorded label set.
  4. If calibrator_store_dir is set, it resolves to an existing directory with store.json.
  5. If the calibrator store has model-binding metadata, it matches the policy's model binding.

Parameters:

Name Type Description Default
policy InferencePolicy

The policy to validate.

required
models_dir Path

The models/ directory (paths are resolved relative to models_dir.parent).

required

Raises:

Type Description
PolicyValidationError

When any check fails.

Source code in src/taskclf/core/inference_policy.py
def validate_policy(
    policy: InferencePolicy,
    models_dir: Path,
) -> None:
    """Validate that *policy* references artifacts that exist and are compatible.

    Checks:

    1. ``model_dir`` resolves to an existing directory with ``metadata.json``.
    2. ``model_schema_hash`` matches the bundle's recorded schema hash.
    3. ``model_label_set`` matches the bundle's recorded label set.
    4. If ``calibrator_store_dir`` is set, it resolves to an existing
       directory with ``store.json``.
    5. If the calibrator store has model-binding metadata, it matches the
       policy's model binding.

    Args:
        policy: The policy to validate.
        models_dir: The ``models/`` directory (paths are resolved
            relative to ``models_dir.parent``).

    Raises:
        PolicyValidationError: When any check fails.
    """
    base = models_dir.parent
    bundle_path = base / policy.model_dir
    if not bundle_path.is_dir():
        raise PolicyValidationError(
            f"Model directory does not exist: {bundle_path}",
            {"model_dir": policy.model_dir},
        )

    meta_path = bundle_path / "metadata.json"
    if not meta_path.is_file():
        raise PolicyValidationError(
            f"Model metadata not found: {meta_path}",
            {"model_dir": policy.model_dir},
        )

    try:
        meta = json.loads(meta_path.read_text())
    except (json.JSONDecodeError, OSError) as exc:
        raise PolicyValidationError(
            f"Could not read model metadata: {exc}",
            {"model_dir": policy.model_dir},
        ) from exc

    bundle_hash = meta.get("schema_hash", "")
    if bundle_hash != policy.model_schema_hash:
        raise PolicyValidationError(
            f"Schema hash mismatch: policy has {policy.model_schema_hash!r}, "
            f"bundle has {bundle_hash!r}",
            {
                "policy_hash": policy.model_schema_hash,
                "bundle_hash": bundle_hash,
            },
        )

    bundle_labels = sorted(meta.get("label_set", []))
    if bundle_labels != sorted(policy.model_label_set):
        raise PolicyValidationError(
            f"Label set mismatch: policy has {sorted(policy.model_label_set)!r}, "
            f"bundle has {bundle_labels!r}",
        )

    if policy.calibrator_store_dir is not None:
        store_path = base / policy.calibrator_store_dir
        if not store_path.is_dir():
            raise PolicyValidationError(
                f"Calibrator store directory does not exist: {store_path}",
                {"calibrator_store_dir": policy.calibrator_store_dir},
            )
        store_meta_path = store_path / "store.json"
        if not store_meta_path.is_file():
            raise PolicyValidationError(
                f"Calibrator store metadata not found: {store_meta_path}",
                {"calibrator_store_dir": policy.calibrator_store_dir},
            )

        try:
            store_meta = json.loads(store_meta_path.read_text())
        except (json.JSONDecodeError, OSError) as exc:
            raise PolicyValidationError(
                f"Could not read calibrator store metadata: {exc}",
                {"calibrator_store_dir": policy.calibrator_store_dir},
            ) from exc

        store_model_hash = store_meta.get("model_schema_hash")
        if (
            store_model_hash is not None
            and store_model_hash != policy.model_schema_hash
        ):
            raise PolicyValidationError(
                f"Calibrator store was fitted against a different schema: "
                f"store has {store_model_hash!r}, "
                f"policy expects {policy.model_schema_hash!r}",
                {
                    "store_schema_hash": store_model_hash,
                    "policy_schema_hash": policy.model_schema_hash,
                },
            )