File size: 10,459 Bytes
feb1b1c
 
9e82114
 
 
 
 
 
 
 
 
 
 
fc87e83
 
9e82114
 
 
feb1b1c
9e82114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fc87e83
 
 
 
 
9e82114
fc87e83
 
 
 
 
 
 
 
 
 
 
 
 
 
9e82114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fc87e83
9e82114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fc87e83
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
from __future__ import annotations

import math
from collections.abc import Mapping
from datetime import date
from types import MappingProxyType
from typing import Final, final

from pydantic import BaseModel, ConfigDict, Field, field_validator

from redstack.domain.enums import EvidenceKind
from redstack.domain.ids import UnitScore
from redstack.domain.provenance import EvidenceRef
from redstack.domain.source import RawCandidate
from redstack.features.parsing import resolve_path

_VO = ConfigDict(
    frozen=True, extra="forbid", str_strip_whitespace=True, validate_default=True
)

FeatureId = str
CellEmission = tuple[tuple[FeatureId, "FeatureCell"], ...]


# --------------------------------------------------------------------------- #
# Pure numeric helpers (shared normalization vocabulary).                     #
# --------------------------------------------------------------------------- #
def clamp_unit(value: float) -> float:
    """Clamp a finite float into ``[0, 1]``; a non-finite input is a bug β†’ raise."""
    if not math.isfinite(value):
        raise ValueError(f"clamp_unit received a non-finite value: {value!r}")
    if value <= 0.0:
        return 0.0
    if value >= 1.0:
        return 1.0
    return float(value)


def unit(value: float) -> UnitScore:
    """Construct a ``UnitScore`` from a float, clamping into ``[0, 1]``."""
    return UnitScore(clamp_unit(value))


def bounded_log_scale(count: float, *, saturation: float) -> float:
    """Map a non-negative count onto ``[0, 1]`` with diminishing returns.

    ``log1p(count) / log1p(saturation)`` β€” a count equal to ``saturation`` maps
    to ~1.0; growth past it is clamped. Negative counts (sentinels) clamp to 0.
    """
    if saturation <= 0.0:
        raise ValueError("saturation must be positive")
    safe = count if count > 0.0 else 0.0
    return clamp_unit(math.log1p(safe) / math.log1p(saturation))


def inverse_bounded(value: float, *, scale: float) -> float:
    """Map a non-negative magnitude onto ``(0, 1]`` decreasing in ``value``.

    ``scale / (scale + value)`` β€” ``value == 0`` β†’ 1.0, ``value == scale`` β†’ 0.5.
    Used for "smaller is better" quantities such as response time in hours.
    """
    if scale <= 0.0:
        raise ValueError("scale must be positive")
    safe = value if value > 0.0 else 0.0
    return clamp_unit(scale / (scale + safe))


def recency_unit(days_elapsed: float, *, half_life_days: float) -> float:
    """Exponential recency in ``[0, 1]``: 1.0 today, 0.5 at one half-life.

    A negative ``days_elapsed`` (a future date relative to ``as_of``) is treated
    as 0 days (fully recent) here; the *impossibility* of a future date is the
    honeypot layer's job, not the normalizer's.
    """
    if half_life_days <= 0.0:
        raise ValueError("half_life_days must be positive")
    safe = days_elapsed if days_elapsed > 0.0 else 0.0
    return clamp_unit(math.pow(0.5, safe / half_life_days))


def days_between(later: date, earlier: date) -> int:
    """Signed day delta ``later - earlier`` (negative if ``later`` precedes)."""
    return (later - earlier).days


def mean_of(values: tuple[float, ...]) -> float:
    """Arithmetic mean of a non-empty tuple; empty β†’ 0.0 (neutral)."""
    if not values:
        return 0.0
    return math.fsum(values) / len(values)


def make_evidence(
    kind: EvidenceKind,
    path: str,
    value: str | int | float | bool,
    *,
    raw: RawCandidate | None = None,
) -> EvidenceRef:
    """Mint an ``EvidenceRef``; ``date`` callers pass ``.isoformat()`` strings.

    When ``raw`` is given, ``path`` is verified to resolve inside it before
    the ref is minted -- a dangling path (wrong index, renamed field) raises
    ``ProvenanceError`` immediately rather than shipping a citation nothing
    backs. ``value`` is kept as the caller supplied it (it may be a derived
    label, not the literal scalar at ``path``); only existence is checked.
    Callers citing a literal ``RawCandidate`` field must pass ``raw``.
    ``EvidenceKind.DERIVED`` evidence (and citations of fields on an
    already-validated domain profile, where no raw record exists to dangle
    against) may omit it.
    """
    if raw is not None:
        resolve_path(raw, path)
    return EvidenceRef(kind=kind, path=path, value=value)


# --------------------------------------------------------------------------- #
# Feature cell.                                                               #
# --------------------------------------------------------------------------- #
@final
class FeatureCell(BaseModel):
    """One feature's ``(value, confidence, evidence)`` output.

    ``value`` carries no range constraint here beyond finiteness β€” the per-index
    bounds in ``FeatureLayout`` are checked when the cell folds into the CQV.
    ``evidence`` is non-empty by construction: a feature with no evidence cannot
    be cited by Reasoning, so emitting one would be a silent hallucination risk.
    """

    model_config = _VO

    value: float = Field(allow_inf_nan=False)
    confidence: UnitScore = Field(ge=0.0, le=1.0, allow_inf_nan=False)
    evidence: tuple[EvidenceRef, ...] = Field(min_length=1)

    @field_validator("confidence", mode="after")
    @classmethod
    def _confidence_unit(cls, value: float) -> UnitScore:
        return UnitScore(value)


def cell(
    value: float, confidence: float, evidence: tuple[EvidenceRef, ...]
) -> FeatureCell:
    """Build a ``FeatureCell``, clamping ``confidence`` into ``[0, 1]``."""
    return FeatureCell(value=value, confidence=unit(confidence), evidence=evidence)


def group_of(feature_id: str) -> str:
    """Group prefix (text before the first dot) of a feature id."""
    return feature_id.split(".", 1)[0]


# --------------------------------------------------------------------------- #
# Read-only feature view (Part 9 β€” the sole engine read surface).             #
# --------------------------------------------------------------------------- #
@final
class FeatureView(BaseModel):
    """Typed, read-only accessor over one candidate's cells + group confidence.

    Engines never touch raw arrays; they resolve features by id through this
    view. Construction is via ``from_cells`` (which derives group confidence as
    the mean of each group's member-cell confidences). All three accessors are
    pure and deterministic.
    """

    model_config = _VO

    cells: Mapping[FeatureId, FeatureCell]
    group_confidences: Mapping[str, float]
    importances: Mapping[FeatureId, float]

    @field_validator("cells", mode="after")
    @classmethod
    def _freeze_cells(
        cls, value: Mapping[FeatureId, FeatureCell]
    ) -> Mapping[FeatureId, FeatureCell]:
        return MappingProxyType(dict(value))

    @field_validator("group_confidences", mode="after")
    @classmethod
    def _freeze_group_conf(cls, value: Mapping[str, float]) -> Mapping[str, float]:
        for group, conf in value.items():
            if not (0.0 <= conf <= 1.0):
                raise ValueError(f"group_confidence for {group!r} not in [0, 1]")
        return MappingProxyType(dict(value))

    @field_validator("importances", mode="after")
    @classmethod
    def _freeze_importance(
        cls, value: Mapping[FeatureId, float]
    ) -> Mapping[FeatureId, float]:
        for feature_id, weight in value.items():
            if not math.isfinite(weight):
                raise ValueError(f"importance for {feature_id!r} is not finite")
        return MappingProxyType(dict(value))

    # -- the Part 9 contract --------------------------------------------- #
    def get(self, feature_id: FeatureId) -> FeatureCell:
        """Resolve a feature's cell. Unknown id β†’ ``KeyError`` (programming error)."""
        return self.cells[feature_id]

    def group_confidence(self, group: str) -> UnitScore:
        """Group-granular confidence. Unknown group β†’ ``KeyError``."""
        return UnitScore(self.group_confidences[group])

    def importance(self, feature_id: FeatureId) -> float:
        """Learned importance; a feature with no learned weight returns ``0.0``."""
        return self.importances.get(feature_id, 0.0)

    # -- convenience (still read-only) ----------------------------------- #
    def has(self, feature_id: FeatureId) -> bool:
        """Whether a cell was emitted for ``feature_id``."""
        return feature_id in self.cells

    def value_of(self, feature_id: FeatureId, default: float = 0.0) -> float:
        """The cell value, or ``default`` if the feature was not emitted."""
        found = self.cells.get(feature_id)
        return found.value if found is not None else default

    @classmethod
    def from_cells(
        cls,
        cells: Mapping[FeatureId, FeatureCell],
        *,
        importances: Mapping[FeatureId, float] | None = None,
    ) -> FeatureView:
        """Assemble a view, deriving group confidence as the per-group mean.

        Engines build the full ``{feature_id: FeatureCell}`` map from every
        extractor, then hand it here; group confidence is the deterministic mean
        of member-cell confidences (Part 7: confidence is stored at group
        granularity).
        """
        grouped: dict[str, list[float]] = {}
        for feature_id, feature_cell in cells.items():
            grouped.setdefault(group_of(feature_id), []).append(
                float(feature_cell.confidence)
            )
        group_conf = {
            group: math.fsum(confs) / len(confs) for group, confs in grouped.items()
        }
        return cls(
            cells=dict(cells),
            group_confidences=group_conf,
            importances=dict(importances) if importances is not None else {},
        )


# Saturation / scale constants shared by the extractors (documented once here).
ENDORSEMENT_SATURATION: Final[float] = 50.0
DURATION_SATURATION_MONTHS: Final[float] = 36.0
ACTIVITY_HALF_LIFE_DAYS: Final[float] = 90.0
STALE_HALF_LIFE_DAYS: Final[float] = 180.0
RESPONSE_TIME_SCALE_HOURS: Final[float] = 24.0


__all__ = (
    "ACTIVITY_HALF_LIFE_DAYS",
    "CellEmission",
    "DURATION_SATURATION_MONTHS",
    "ENDORSEMENT_SATURATION",
    "FeatureCell",
    "FeatureId",
    "FeatureView",
    "RESPONSE_TIME_SCALE_HOURS",
    "STALE_HALF_LIFE_DAYS",
    "bounded_log_scale",
    "cell",
    "clamp_unit",
    "days_between",
    "group_of",
    "inverse_bounded",
    "make_evidence",
    "mean_of",
    "recency_unit",
    "unit",
)