Skip to content

Commit 140a6ae

Browse files
authored
Merge pull request #294 from QuantStrategyLab/feat/risk-observation-reader
feat(risk): add exact private observation reader port
2 parents c2de540 + e3ffb24 commit 140a6ae

3 files changed

Lines changed: 267 additions & 0 deletions

File tree

docs/qsl_long_horizon_risk_composer_v1.zh-CN.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@
2222

2323
命令行也遵守这条分工:已有 owner-bound 输入可用 `--input`;私有观察件必须同时给出 `--observation``--risk-preference`。缺少偏好即失败,不会静默选择“均衡”或任何默认档位。
2424

25+
## 受限私有读取口
26+
27+
`python/scripts/long_horizon_risk_observation_ingress.py` 定义了控制面唯一需要的读取能力:按 `long-horizon-risk-observations/v1/<candidate-id>/<p3-evidence-sha256>.json` 精确读取一个不超过 2 MiB 的观察件,核验对象内容的哈希、candidate 与 P3 摘要,再交给 Composer。它只接收调用方注入的 `read_exact` 函数;没有云 SDK、凭据、网络、bucket、列举、猜测最新、重试替代对象、写入、覆盖或删除能力。
28+
29+
这意味着未来任一已授权的私有存储实现都只能获得该精确对象的读权限。存储未配置、读错、超限、JSON/哈希异常或身份不匹配都会闭合为不可用,不会降级到 Actions artifact、公开仓库、控制台或 AI 上下文。当前仍没有真实存储 adapter、运行身份、scheduler 或政策写入。
30+
2531
## 必要证据和计算方法
2632

2733
输入必须精确绑定 candidate revision 与 P1/P2/P3/plugin 摘要,并至少包含每类一个完整的 252-session 以上路径:
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
#!/usr/bin/env python3
2+
"""Read one exact private long-horizon P3 observation through an injected port.
3+
4+
This module deliberately has no cloud SDK, credential, network, bucket,
5+
listing, write, delete, broker, account, scheduler, policy-write, or execution
6+
dependency. A future runtime may inject a narrowly scoped reader with access
7+
to one protected storage namespace. This core only derives an immutable name,
8+
reads that exact object once, validates the hash-bound observation, and can
9+
produce a non-sensitive Composer recommendation.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import re
15+
from collections.abc import Callable
16+
from typing import Any
17+
18+
from long_horizon_risk_composer import (
19+
LongHorizonRiskComposerError,
20+
build_risk_composer_input_from_observation,
21+
compose_long_horizon_risk_recommendation,
22+
parse_risk_composer_input_json,
23+
validate_long_horizon_risk_observation,
24+
)
25+
26+
27+
PRIVATE_OBSERVATION_OBJECT_PREFIX = "long-horizon-risk-observations/v1"
28+
MAX_PRIVATE_OBSERVATION_BYTES = 2 * 1024 * 1024
29+
_IDENTITY_PATTERN = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$")
30+
_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
31+
32+
33+
class LongHorizonRiskObservationIngressError(ValueError):
34+
"""Fail-closed ingress error that contains no object name or path data."""
35+
36+
37+
def _fail() -> None:
38+
raise LongHorizonRiskObservationIngressError("private long-horizon risk observation unavailable")
39+
40+
41+
def private_observation_object_name(*, candidate_id: str, p3_evidence_sha256: str) -> str:
42+
"""Return the only readable object name for one candidate/P3 identity."""
43+
if not _IDENTITY_PATTERN.fullmatch(candidate_id) or not _SHA256_PATTERN.fullmatch(p3_evidence_sha256):
44+
_fail()
45+
return f"{PRIVATE_OBSERVATION_OBJECT_PREFIX}/{candidate_id}/{p3_evidence_sha256}.json"
46+
47+
48+
def load_private_long_horizon_risk_observation(
49+
*,
50+
candidate_id: str,
51+
p3_evidence_sha256: str,
52+
read_exact: Callable[[str], bytes],
53+
) -> dict[str, Any]:
54+
"""Read and validate one exact observation; unavailable input never falls back.
55+
56+
``read_exact`` must be a capability-scoped dependency. This function calls
57+
it once with the deterministic object name and has no mechanism to list,
58+
search, retry with another name, write, overwrite, or delete objects.
59+
"""
60+
if not callable(read_exact):
61+
_fail()
62+
object_name = private_observation_object_name(
63+
candidate_id=candidate_id,
64+
p3_evidence_sha256=p3_evidence_sha256,
65+
)
66+
try:
67+
raw = read_exact(object_name)
68+
except Exception as exc: # pragma: no cover - injected I/O boundary
69+
raise LongHorizonRiskObservationIngressError(
70+
"private long-horizon risk observation unavailable"
71+
) from exc
72+
if not isinstance(raw, bytes) or not raw or len(raw) > MAX_PRIVATE_OBSERVATION_BYTES:
73+
_fail()
74+
try:
75+
observation = validate_long_horizon_risk_observation(parse_risk_composer_input_json(raw.decode("utf-8")))
76+
except (UnicodeDecodeError, LongHorizonRiskComposerError) as exc:
77+
raise LongHorizonRiskObservationIngressError(
78+
"private long-horizon risk observation unavailable"
79+
) from exc
80+
if (
81+
observation["candidate"]["candidate_id"] != candidate_id
82+
or observation["source_evidence"]["p3_evidence_sha256"] != p3_evidence_sha256
83+
):
84+
_fail()
85+
return observation
86+
87+
88+
def compose_from_private_long_horizon_risk_observation(
89+
*,
90+
candidate_id: str,
91+
p3_evidence_sha256: str,
92+
risk_preference: str,
93+
read_exact: Callable[[str], bytes],
94+
) -> dict[str, Any]:
95+
"""Return only the Composer's safe recommendation from one private object."""
96+
observation = load_private_long_horizon_risk_observation(
97+
candidate_id=candidate_id,
98+
p3_evidence_sha256=p3_evidence_sha256,
99+
read_exact=read_exact,
100+
)
101+
try:
102+
composer_input = build_risk_composer_input_from_observation(
103+
observation,
104+
risk_preference=risk_preference,
105+
)
106+
return compose_long_horizon_risk_recommendation(composer_input)
107+
except LongHorizonRiskComposerError as exc:
108+
raise LongHorizonRiskObservationIngressError(
109+
"private long-horizon risk observation unavailable"
110+
) from exc
111+
112+
113+
__all__ = [
114+
"LongHorizonRiskObservationIngressError",
115+
"MAX_PRIVATE_OBSERVATION_BYTES",
116+
"PRIVATE_OBSERVATION_OBJECT_PREFIX",
117+
"compose_from_private_long_horizon_risk_observation",
118+
"load_private_long_horizon_risk_observation",
119+
"private_observation_object_name",
120+
]
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
from __future__ import annotations
2+
3+
import copy
4+
import importlib.util
5+
import json
6+
import sys
7+
import unittest
8+
from pathlib import Path
9+
10+
11+
ROOT = Path(__file__).resolve().parents[1]
12+
SCRIPTS = ROOT / "scripts"
13+
14+
15+
def _load_module(name: str):
16+
spec = importlib.util.spec_from_file_location(name, SCRIPTS / f"{name}.py")
17+
module = importlib.util.module_from_spec(spec)
18+
assert spec.loader is not None
19+
sys.modules[spec.name] = module
20+
spec.loader.exec_module(module)
21+
return module
22+
23+
24+
composer = _load_module("long_horizon_risk_composer")
25+
ingress = _load_module("long_horizon_risk_observation_ingress")
26+
27+
28+
class LongHorizonRiskObservationIngressTest(unittest.TestCase):
29+
@staticmethod
30+
def _returns(gain_bps: int, drawdown_bps: int) -> list[int]:
31+
return [gain_bps] * 240 + [-drawdown_bps] * 12
32+
33+
def _observation(self) -> dict[str, object]:
34+
paths = [
35+
{
36+
"scenario_id": f"soxl_soxx_{kind.lower()}_{index}",
37+
"scenario_kind": kind,
38+
"session_count": 253,
39+
"strategy_returns_bps": self._returns(16 - index, 124 + index),
40+
"benchmark_returns_bps": self._returns(10 - index, 100 + index),
41+
}
42+
for index, kind in enumerate(("WALK_FORWARD", "BOOTSTRAP", "STRESS"), start=1)
43+
]
44+
result: dict[str, object] = {
45+
"schema": "qsl.long_horizon_risk_observation.v1",
46+
"candidate": {
47+
"candidate_id": "soxl_soxx_longterm_compounding",
48+
"candidate_kind": "individual",
49+
"strategy_repository": "QuantStrategyLab/UsEquityStrategies",
50+
"strategy_revision": "a" * 40,
51+
},
52+
"source_evidence": {
53+
"p1_input_digest": "1" * 64,
54+
"p2_config_digest": "2" * 64,
55+
"p3_evidence_sha256": "3" * 64,
56+
"plugin_bundle_sha256": "4" * 64,
57+
},
58+
"benchmark": {
59+
"benchmark_id": "soxx",
60+
"benchmark_kind": "unlevered_reference",
61+
"sessions_per_year": 252,
62+
},
63+
"scenario_paths": paths,
64+
"observation_sha256": "",
65+
}
66+
result["observation_sha256"] = composer.calculate_risk_observation_sha256(result)
67+
return result
68+
69+
def test_reads_only_the_exact_candidate_and_p3_object_then_returns_a_redacted_recommendation(self):
70+
observation = self._observation()
71+
raw = json.dumps(observation, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
72+
calls: list[str] = []
73+
74+
result = ingress.compose_from_private_long_horizon_risk_observation(
75+
candidate_id="soxl_soxx_longterm_compounding",
76+
p3_evidence_sha256="3" * 64,
77+
risk_preference="BALANCED_COMPOUNDING",
78+
read_exact=lambda object_name: calls.append(object_name) or raw,
79+
)
80+
81+
self.assertEqual(
82+
calls,
83+
[
84+
"long-horizon-risk-observations/v1/soxl_soxx_longterm_compounding/"
85+
+ ("3" * 64)
86+
+ ".json"
87+
],
88+
)
89+
self.assertEqual(result["status"], "ADVISORY_RECOMMENDATION_READY")
90+
serialized = json.dumps(result, sort_keys=True).lower()
91+
self.assertNotIn("strategy_returns", serialized)
92+
self.assertNotIn("benchmark_returns", serialized)
93+
self.assertNotIn("broker", serialized)
94+
self.assertNotIn("account", serialized)
95+
96+
def test_reader_failure_tampering_and_identity_mismatch_fail_closed_without_fallback(self):
97+
observation = self._observation()
98+
raw = json.dumps(observation).encode("utf-8")
99+
with self.assertRaisesRegex(ingress.LongHorizonRiskObservationIngressError, "unavailable"):
100+
ingress.load_private_long_horizon_risk_observation(
101+
candidate_id="soxl_soxx_longterm_compounding",
102+
p3_evidence_sha256="3" * 64,
103+
read_exact=lambda _object_name: (_ for _ in ()).throw(RuntimeError("storage hostname")),
104+
)
105+
106+
tampered = copy.deepcopy(observation)
107+
tampered["scenario_paths"][0]["strategy_returns_bps"][0] = 99
108+
with self.assertRaisesRegex(ingress.LongHorizonRiskObservationIngressError, "unavailable"):
109+
ingress.load_private_long_horizon_risk_observation(
110+
candidate_id="soxl_soxx_longterm_compounding",
111+
p3_evidence_sha256="3" * 64,
112+
read_exact=lambda _object_name: json.dumps(tampered).encode("utf-8"),
113+
)
114+
115+
with self.assertRaisesRegex(ingress.LongHorizonRiskObservationIngressError, "unavailable"):
116+
ingress.load_private_long_horizon_risk_observation(
117+
candidate_id="soxl_soxx_longterm_compounding",
118+
p3_evidence_sha256="4" * 64,
119+
read_exact=lambda _object_name: raw,
120+
)
121+
122+
def test_invalid_identities_and_oversized_input_never_reach_the_reader(self):
123+
calls: list[str] = []
124+
with self.assertRaisesRegex(ingress.LongHorizonRiskObservationIngressError, "unavailable"):
125+
ingress.load_private_long_horizon_risk_observation(
126+
candidate_id="../latest",
127+
p3_evidence_sha256="3" * 64,
128+
read_exact=lambda object_name: calls.append(object_name) or b"{}",
129+
)
130+
self.assertEqual(calls, [])
131+
132+
with self.assertRaisesRegex(ingress.LongHorizonRiskObservationIngressError, "unavailable"):
133+
ingress.load_private_long_horizon_risk_observation(
134+
candidate_id="soxl_soxx_longterm_compounding",
135+
p3_evidence_sha256="3" * 64,
136+
read_exact=lambda _object_name: b"x" * (ingress.MAX_PRIVATE_OBSERVATION_BYTES + 1),
137+
)
138+
139+
140+
if __name__ == "__main__":
141+
unittest.main()

0 commit comments

Comments
 (0)