assay-engine 0.5.0.dev2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- assay/__init__.py +83 -0
- assay/_cli_app.py +102 -0
- assay/_cli_io.py +130 -0
- assay/_json.py +34 -0
- assay/_optional.py +69 -0
- assay/_version.py +5 -0
- assay/additive.py +104 -0
- assay/agreement.py +312 -0
- assay/calibration.py +167 -0
- assay/cli.py +55 -0
- assay/compose.py +31 -0
- assay/composite.py +228 -0
- assay/contracts.py +886 -0
- assay/errors.py +166 -0
- assay/limits.py +13 -0
- assay/measurement.py +1325 -0
- assay/metrics.py +238 -0
- assay/minimum.py +82 -0
- assay/models.py +111 -0
- assay/normalize.py +74 -0
- assay/py.typed +0 -0
- assay/ranking.py +361 -0
- assay/settings.py +90 -0
- assay/uncertainty.py +190 -0
- assay/weighted_mean.py +109 -0
- assay_engine-0.5.0.dev2.dist-info/METADATA +250 -0
- assay_engine-0.5.0.dev2.dist-info/RECORD +30 -0
- assay_engine-0.5.0.dev2.dist-info/WHEEL +4 -0
- assay_engine-0.5.0.dev2.dist-info/entry_points.txt +2 -0
- assay_engine-0.5.0.dev2.dist-info/licenses/LICENSE +21 -0
assay/__init__.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Assay combines heterogeneous measurements into explainable scores."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from assay._version import __version__
|
|
6
|
+
from assay.compose import compose
|
|
7
|
+
from assay.contracts import (
|
|
8
|
+
AdditiveRequest,
|
|
9
|
+
AdditiveTerm,
|
|
10
|
+
ClampPolicy,
|
|
11
|
+
Component,
|
|
12
|
+
Direction,
|
|
13
|
+
ExplainedComponent,
|
|
14
|
+
Interval,
|
|
15
|
+
Method,
|
|
16
|
+
MinimumRequest,
|
|
17
|
+
NativeScale,
|
|
18
|
+
Operation,
|
|
19
|
+
ScoreRequest,
|
|
20
|
+
ScoreResult,
|
|
21
|
+
WeightedMeanRequest,
|
|
22
|
+
parse_request,
|
|
23
|
+
parse_request_json,
|
|
24
|
+
)
|
|
25
|
+
from assay.errors import ContractValidationError
|
|
26
|
+
from assay.measurement import (
|
|
27
|
+
AgreementMeasurementRequest,
|
|
28
|
+
AgreementMeasurementResult,
|
|
29
|
+
BinaryMeasurementRequest,
|
|
30
|
+
BinaryMeasurementResult,
|
|
31
|
+
BinaryMetricControls,
|
|
32
|
+
MeasurementRequest,
|
|
33
|
+
MeasurementResult,
|
|
34
|
+
OrdinalRating,
|
|
35
|
+
RankingMeasurementRequest,
|
|
36
|
+
RankingMeasurementResult,
|
|
37
|
+
RankingMetricControls,
|
|
38
|
+
RankingQueryInput,
|
|
39
|
+
RelevanceInput,
|
|
40
|
+
UncertaintyControls,
|
|
41
|
+
measure,
|
|
42
|
+
parse_measurement_json,
|
|
43
|
+
)
|
|
44
|
+
from assay.normalize import normalize
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"AdditiveRequest",
|
|
48
|
+
"AdditiveTerm",
|
|
49
|
+
"AgreementMeasurementRequest",
|
|
50
|
+
"AgreementMeasurementResult",
|
|
51
|
+
"BinaryMeasurementRequest",
|
|
52
|
+
"BinaryMeasurementResult",
|
|
53
|
+
"BinaryMetricControls",
|
|
54
|
+
"ClampPolicy",
|
|
55
|
+
"Component",
|
|
56
|
+
"ContractValidationError",
|
|
57
|
+
"Direction",
|
|
58
|
+
"ExplainedComponent",
|
|
59
|
+
"Interval",
|
|
60
|
+
"MeasurementRequest",
|
|
61
|
+
"MeasurementResult",
|
|
62
|
+
"Method",
|
|
63
|
+
"MinimumRequest",
|
|
64
|
+
"NativeScale",
|
|
65
|
+
"Operation",
|
|
66
|
+
"OrdinalRating",
|
|
67
|
+
"RankingMeasurementRequest",
|
|
68
|
+
"RankingMeasurementResult",
|
|
69
|
+
"RankingMetricControls",
|
|
70
|
+
"RankingQueryInput",
|
|
71
|
+
"RelevanceInput",
|
|
72
|
+
"ScoreRequest",
|
|
73
|
+
"ScoreResult",
|
|
74
|
+
"UncertaintyControls",
|
|
75
|
+
"WeightedMeanRequest",
|
|
76
|
+
"__version__",
|
|
77
|
+
"compose",
|
|
78
|
+
"measure",
|
|
79
|
+
"normalize",
|
|
80
|
+
"parse_measurement_json",
|
|
81
|
+
"parse_request",
|
|
82
|
+
"parse_request_json",
|
|
83
|
+
]
|
assay/_cli_app.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Typer-backed scoring commands, loaded only after migration dispatch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
from typer._click.exceptions import ClickException
|
|
11
|
+
|
|
12
|
+
from assay._cli_io import json_bytes, read_input, write_output
|
|
13
|
+
from assay.compose import compose
|
|
14
|
+
from assay.contracts import ExplainedComponent, Interval, ScoreResult, parse_request_json
|
|
15
|
+
from assay.errors import CliInputInvalid
|
|
16
|
+
from assay.measurement import measure, parse_measurement_json
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(add_completion=False, no_args_is_help=False, pretty_exceptions_enable=False)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.callback()
|
|
22
|
+
def root() -> None:
|
|
23
|
+
"""Combine or inspect typed measurements."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command("compose")
|
|
27
|
+
def compose_command(
|
|
28
|
+
request: Annotated[str, typer.Option("--request")],
|
|
29
|
+
out: Annotated[str | None, typer.Option("--out")] = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Compose one validated scoring request."""
|
|
32
|
+
source = read_input(request)
|
|
33
|
+
result = compose(parse_request_json(source))
|
|
34
|
+
write_output(json_bytes(result), out, request)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command("measure")
|
|
38
|
+
def measure_command(
|
|
39
|
+
request: Annotated[str, typer.Option("--request")],
|
|
40
|
+
out: Annotated[str | None, typer.Option("--out")] = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Run one typed optional measurement family."""
|
|
43
|
+
source = read_input(request)
|
|
44
|
+
result = measure(parse_measurement_json(source))
|
|
45
|
+
write_output(json_bytes(result), out, request)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _number(value: float | None) -> str:
|
|
49
|
+
return "none" if value is None else json.dumps(value, allow_nan=False)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _interval(interval: Interval | None) -> str:
|
|
53
|
+
if interval is None:
|
|
54
|
+
return "deterministic"
|
|
55
|
+
return f"[{_number(interval.low)}, {_number(interval.high)}]"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _selection(component: ExplainedComponent, selected: str | None) -> str:
|
|
59
|
+
if selected is None:
|
|
60
|
+
return ""
|
|
61
|
+
return f"; selected={'yes' if component.id == selected else 'no'}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _component_line(index: int, row: ExplainedComponent, selected: str | None) -> str:
|
|
65
|
+
values = (
|
|
66
|
+
f"raw={_number(row.raw)}; normalized={_number(row.normalized)}",
|
|
67
|
+
f"operation={row.operation}; coefficient={_number(row.coefficient)}",
|
|
68
|
+
f"contribution={_number(row.contribution)}{_selection(row, selected)}",
|
|
69
|
+
)
|
|
70
|
+
return f"{index}. {row.id}: {'; '.join(values)}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _explanation(result: ScoreResult) -> str:
|
|
74
|
+
lines = [
|
|
75
|
+
"Assay score explanation",
|
|
76
|
+
f"Method: {result.method.id}@{result.method.version}",
|
|
77
|
+
f"Score: {_number(result.score)}",
|
|
78
|
+
f"Interval: {_interval(result.interval)}",
|
|
79
|
+
"Components:",
|
|
80
|
+
]
|
|
81
|
+
rows = enumerate(result.components, start=1)
|
|
82
|
+
lines.extend(_component_line(index, row, result.selected_component_id) for index, row in rows)
|
|
83
|
+
return "\n".join(lines) + "\n"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command("explain")
|
|
87
|
+
def explain_command(
|
|
88
|
+
result: Annotated[str, typer.Option("--result")],
|
|
89
|
+
out: Annotated[str | None, typer.Option("--out")] = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Replay result invariants and render deterministic arithmetic."""
|
|
92
|
+
validated = ScoreResult.model_validate_json(read_input(result))
|
|
93
|
+
write_output(_explanation(validated).encode("utf-8"), out, result)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def run(arguments: tuple[str, ...]) -> int:
|
|
97
|
+
"""Run Typer without allowing its usage rendering to cross the boundary."""
|
|
98
|
+
try:
|
|
99
|
+
app(args=list(arguments), standalone_mode=False)
|
|
100
|
+
except (ClickException, SystemExit, ValidationError, RecursionError):
|
|
101
|
+
raise CliInputInvalid from None
|
|
102
|
+
return 0
|
assay/_cli_io.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Value-redacted input and output primitives for the command adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import unicodedata
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Final, Protocol
|
|
12
|
+
|
|
13
|
+
from assay.errors import CliInputInvalid, CliOutputInvalid
|
|
14
|
+
|
|
15
|
+
_MAX_INPUT_BYTES: Final[int] = 1_048_576
|
|
16
|
+
_READ_CHUNK_BYTES: Final[int] = 65_536
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class JsonModel(Protocol):
|
|
20
|
+
"""The one serialization operation needed by the CLI."""
|
|
21
|
+
|
|
22
|
+
def model_dump_json(self, *, by_alias: bool) -> str: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _read_chunks(descriptor: int) -> bytes:
|
|
26
|
+
chunks: list[bytes] = []
|
|
27
|
+
total = 0
|
|
28
|
+
while chunk := os.read(descriptor, _READ_CHUNK_BYTES):
|
|
29
|
+
total += len(chunk)
|
|
30
|
+
if total > _MAX_INPUT_BYTES:
|
|
31
|
+
raise CliInputInvalid
|
|
32
|
+
chunks.append(chunk)
|
|
33
|
+
return b"".join(chunks)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _read_regular(descriptor: int) -> bytes:
|
|
37
|
+
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
|
|
38
|
+
raise CliInputInvalid
|
|
39
|
+
return _read_chunks(descriptor)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def read_input(path: str) -> bytes:
|
|
43
|
+
"""Read one bounded regular file through a single already-open descriptor."""
|
|
44
|
+
descriptor = -1
|
|
45
|
+
try:
|
|
46
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NONBLOCK)
|
|
47
|
+
return _read_regular(descriptor)
|
|
48
|
+
except OSError:
|
|
49
|
+
raise CliInputInvalid from None
|
|
50
|
+
finally:
|
|
51
|
+
if descriptor >= 0:
|
|
52
|
+
os.close(descriptor)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def json_bytes(model: JsonModel) -> bytes:
|
|
56
|
+
"""Serialize exactly once and terminate the public JSON stream with one LF."""
|
|
57
|
+
return model.model_dump_json(by_alias=True).encode("utf-8") + b"\n"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _normalized_path(path: Path) -> str:
|
|
61
|
+
resolved = str(path.resolve(strict=False))
|
|
62
|
+
return unicodedata.normalize("NFC", resolved).casefold()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _same_existing(first: Path, second: Path) -> bool:
|
|
66
|
+
try:
|
|
67
|
+
return first.samefile(second)
|
|
68
|
+
except OSError:
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _reject_alias(source: Path, destination: Path) -> None:
|
|
73
|
+
if _same_existing(source, destination):
|
|
74
|
+
raise CliOutputInvalid
|
|
75
|
+
if _normalized_path(source) == _normalized_path(destination):
|
|
76
|
+
raise CliOutputInvalid
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _require_destination(destination: Path) -> None:
|
|
80
|
+
parent = destination.parent.resolve(strict=True)
|
|
81
|
+
if not parent.is_dir():
|
|
82
|
+
raise CliOutputInvalid
|
|
83
|
+
if destination.is_symlink():
|
|
84
|
+
raise CliOutputInvalid
|
|
85
|
+
if destination.exists() and not destination.is_file():
|
|
86
|
+
raise CliOutputInvalid
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _flush_file(descriptor: int, payload: bytes) -> None:
|
|
90
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
91
|
+
stream.write(payload)
|
|
92
|
+
stream.flush()
|
|
93
|
+
os.fsync(stream.fileno())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _flush_parent(parent: Path) -> None:
|
|
97
|
+
descriptor = os.open(parent, os.O_RDONLY | os.O_CLOEXEC)
|
|
98
|
+
try:
|
|
99
|
+
os.fsync(descriptor)
|
|
100
|
+
finally:
|
|
101
|
+
os.close(descriptor)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _install(payload: bytes, destination: Path) -> None:
|
|
105
|
+
descriptor, stage = tempfile.mkstemp(prefix=".assay-", dir=destination.parent)
|
|
106
|
+
try:
|
|
107
|
+
_flush_file(descriptor, payload)
|
|
108
|
+
os.replace(stage, destination)
|
|
109
|
+
_flush_parent(destination.parent)
|
|
110
|
+
finally:
|
|
111
|
+
Path(stage).unlink(missing_ok=True)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _write_file(payload: bytes, destination: str, source: str) -> None:
|
|
115
|
+
output = Path(destination)
|
|
116
|
+
try:
|
|
117
|
+
_reject_alias(Path(source), output)
|
|
118
|
+
_require_destination(output)
|
|
119
|
+
_install(payload, output)
|
|
120
|
+
except OSError:
|
|
121
|
+
raise CliOutputInvalid from None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def write_output(payload: bytes, destination: str | None, source: str) -> None:
|
|
125
|
+
"""Write exact bytes to stdout or install them crash-safely beside the destination."""
|
|
126
|
+
if destination is not None:
|
|
127
|
+
_write_file(payload, destination, source)
|
|
128
|
+
return
|
|
129
|
+
sys.stdout.buffer.write(payload)
|
|
130
|
+
sys.stdout.buffer.flush()
|
assay/_json.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Dependency-light JSON decoding that rejects ambiguous object members."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from assay.errors import AssayError, ContractCode, ContractValidationError
|
|
8
|
+
|
|
9
|
+
type JsonData = str | bytes | bytearray
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _DuplicateMemberError(Exception):
|
|
13
|
+
"""Private control flow for a repeated decoded object key."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
17
|
+
result: dict[str, object] = {}
|
|
18
|
+
for key, value in pairs:
|
|
19
|
+
if key in result:
|
|
20
|
+
raise _DuplicateMemberError
|
|
21
|
+
result[key] = value
|
|
22
|
+
return result
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def decode_json(data: JsonData, invalid_error: type[AssayError]) -> object:
|
|
26
|
+
"""Decode one JSON value without last-wins member collapse or raw exceptions."""
|
|
27
|
+
error: AssayError
|
|
28
|
+
try:
|
|
29
|
+
return json.loads(data, object_pairs_hook=_unique_object)
|
|
30
|
+
except _DuplicateMemberError:
|
|
31
|
+
error = ContractValidationError(ContractCode.DUPLICATE_FIELD)
|
|
32
|
+
except (ValueError, UnicodeDecodeError, TypeError, RecursionError):
|
|
33
|
+
error = invalid_error()
|
|
34
|
+
raise error from None
|
assay/_optional.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Lazy, redacted boundary for Assay's optional metric dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from functools import cache
|
|
7
|
+
from importlib import import_module
|
|
8
|
+
from types import ModuleType
|
|
9
|
+
from typing import cast
|
|
10
|
+
|
|
11
|
+
from assay.errors import MetricsExtraMissing
|
|
12
|
+
|
|
13
|
+
type OptionalCallable = Callable[..., object]
|
|
14
|
+
|
|
15
|
+
_FAILED = object()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _try_import(name: str) -> ModuleType | None:
|
|
19
|
+
try:
|
|
20
|
+
return import_module(name)
|
|
21
|
+
except Exception:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@cache
|
|
26
|
+
def load_module(name: str) -> ModuleType:
|
|
27
|
+
"""Load one exact optional module, caching successful imports only."""
|
|
28
|
+
module = _try_import(name)
|
|
29
|
+
if module is None:
|
|
30
|
+
raise MetricsExtraMissing
|
|
31
|
+
return module
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _try_attribute(module: ModuleType, name: str) -> object:
|
|
35
|
+
try:
|
|
36
|
+
return getattr(module, name)
|
|
37
|
+
except Exception:
|
|
38
|
+
return _FAILED
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@cache
|
|
42
|
+
def load_object(module_name: str, name: str) -> object:
|
|
43
|
+
"""Load one exact dependency attribute, never caching a missing attribute."""
|
|
44
|
+
value = _try_attribute(load_module(module_name), name)
|
|
45
|
+
if value is _FAILED:
|
|
46
|
+
raise MetricsExtraMissing
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@cache
|
|
51
|
+
def load_callable(module_name: str, name: str) -> OptionalCallable:
|
|
52
|
+
"""Load one exact dependency callable, caching only a callable result."""
|
|
53
|
+
value = load_object(module_name, name)
|
|
54
|
+
if not callable(value):
|
|
55
|
+
raise MetricsExtraMissing
|
|
56
|
+
return cast(OptionalCallable, value)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def call_dependency(function: OptionalCallable, *args: object, **kwargs: object) -> object:
|
|
60
|
+
"""Call a dependency without allowing its private exception to cross the boundary."""
|
|
61
|
+
try:
|
|
62
|
+
return function(*args, **kwargs)
|
|
63
|
+
except Exception:
|
|
64
|
+
return _FAILED
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def dependency_failed(value: object) -> bool:
|
|
68
|
+
"""Return whether a redacted dependency invocation failed."""
|
|
69
|
+
return value is _FAILED
|
assay/_version.py
ADDED
assay/additive.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Declared-order additive and subtractive score composition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import NoReturn
|
|
6
|
+
|
|
7
|
+
from assay.composite import canonical_zero, finite_output, inputs_hash, interval_or_none
|
|
8
|
+
from assay.contracts import (
|
|
9
|
+
AdditiveRequest,
|
|
10
|
+
AdditiveTerm,
|
|
11
|
+
ClampPolicy,
|
|
12
|
+
ExplainedComponent,
|
|
13
|
+
Interval,
|
|
14
|
+
Method,
|
|
15
|
+
Operation,
|
|
16
|
+
ScoreResult,
|
|
17
|
+
)
|
|
18
|
+
from assay.errors import ContractCode, ContractValidationError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _fail(code: ContractCode) -> NoReturn:
|
|
22
|
+
raise ContractValidationError(code) from None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _contribution(term: AdditiveTerm, value: float | None = None) -> float:
|
|
26
|
+
raw = term.value if value is None else value
|
|
27
|
+
return finite_output(raw * term.coefficient)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _apply(total: float, contribution: float, operation: Operation) -> float:
|
|
31
|
+
if operation is Operation.ADD:
|
|
32
|
+
return finite_output(total + contribution)
|
|
33
|
+
return finite_output(total - contribution)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _final(value: float, policy: ClampPolicy | None) -> float:
|
|
37
|
+
result = finite_output(value)
|
|
38
|
+
if policy is None:
|
|
39
|
+
return result
|
|
40
|
+
if policy is ClampPolicy.CLAMP:
|
|
41
|
+
return canonical_zero(min(1.0, max(0.0, result)))
|
|
42
|
+
if not 0.0 <= result <= 1.0:
|
|
43
|
+
_fail(ContractCode.OUT_OF_RANGE)
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _explain(term: AdditiveTerm) -> ExplainedComponent:
|
|
48
|
+
return ExplainedComponent(
|
|
49
|
+
id=term.id,
|
|
50
|
+
raw=term.value,
|
|
51
|
+
normalized=None,
|
|
52
|
+
declared_weight=None,
|
|
53
|
+
operation=term.operation,
|
|
54
|
+
coefficient=term.coefficient,
|
|
55
|
+
contribution=_contribution(term),
|
|
56
|
+
contribution_interval=interval_or_none(*_term_bounds(term)),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _point(request: AdditiveRequest, rows: tuple[ExplainedComponent, ...]) -> float:
|
|
61
|
+
total = finite_output(request.intercept)
|
|
62
|
+
for row in rows:
|
|
63
|
+
total = _apply(total, row.contribution, row.operation)
|
|
64
|
+
return _final(total, request.clamp)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _term_bounds(term: AdditiveTerm) -> tuple[float, float]:
|
|
68
|
+
if term.interval is None:
|
|
69
|
+
contribution = _contribution(term)
|
|
70
|
+
return contribution, contribution
|
|
71
|
+
return _contribution(term, term.interval.low), _contribution(term, term.interval.high)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _advance_bounds(low: float, high: float, term: AdditiveTerm) -> tuple[float, float]:
|
|
75
|
+
term_low, term_high = _term_bounds(term)
|
|
76
|
+
if term.operation is Operation.ADD:
|
|
77
|
+
return finite_output(low + term_low), finite_output(high + term_high)
|
|
78
|
+
return finite_output(low - term_high), finite_output(high - term_low)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _result_interval(request: AdditiveRequest) -> Interval | None:
|
|
82
|
+
if not any(term.interval is not None for term in request.terms):
|
|
83
|
+
return None
|
|
84
|
+
low = high = finite_output(request.intercept)
|
|
85
|
+
for term in request.terms:
|
|
86
|
+
low, high = _advance_bounds(low, high, term)
|
|
87
|
+
return interval_or_none(_final(low, request.clamp), _final(high, request.clamp))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def additive(request: AdditiveRequest) -> ScoreResult:
|
|
91
|
+
"""Compose an explicit left-to-right sum with an optional final unit bound."""
|
|
92
|
+
validated = AdditiveRequest.model_validate(request)
|
|
93
|
+
rows = tuple(_explain(term) for term in validated.terms)
|
|
94
|
+
return ScoreResult(
|
|
95
|
+
method=Method(id=validated.method, version=validated.method_version),
|
|
96
|
+
score=_point(validated, rows),
|
|
97
|
+
interval=_result_interval(validated),
|
|
98
|
+
clamp=validated.clamp,
|
|
99
|
+
intercept=validated.intercept,
|
|
100
|
+
weight_total=None,
|
|
101
|
+
components=rows,
|
|
102
|
+
inputs_hash=inputs_hash(validated),
|
|
103
|
+
selected_component_id=None,
|
|
104
|
+
)
|