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 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
@@ -0,0 +1,5 @@
1
+ """Single source of truth for the Assay Python distribution version."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.5.0.dev2"
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
+ )