|
29 | 29 |
|
30 | 30 | RISK_COMPOSER_INPUT_SCHEMA_ID = "qsl.long_horizon_risk_composer_input.v1" |
31 | 31 | RISK_COMPOSER_RECOMMENDATION_SCHEMA_ID = "qsl.long_horizon_risk_composer_recommendation.v1" |
| 32 | +RISK_OBSERVATION_SCHEMA_ID = "qsl.long_horizon_risk_observation.v1" |
32 | 33 | _IDENTITY_PATTERN = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$") |
33 | 34 | _REPOSITORY_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$") |
34 | 35 | _REVISION_PATTERN = re.compile(r"^[0-9a-f]{40}$") |
|
41 | 42 | re.IGNORECASE, |
42 | 43 | ) |
43 | 44 | _INPUT_FIELDS = {"schema", "candidate", "source_evidence", "objective", "scenario_paths", "input_sha256"} |
| 45 | +_OBSERVATION_FIELDS = {"schema", "candidate", "source_evidence", "benchmark", "scenario_paths", "observation_sha256"} |
44 | 46 | _CANDIDATE_FIELDS = {"candidate_id", "candidate_kind", "strategy_repository", "strategy_revision"} |
45 | 47 | _SOURCE_EVIDENCE_FIELDS = {"p1_input_digest", "p2_config_digest", "p3_evidence_sha256", "plugin_bundle_sha256"} |
46 | 48 | _OBJECTIVE_FIELDS = {"risk_preference", "benchmark_id", "benchmark_kind", "sessions_per_year"} |
| 49 | +_BENCHMARK_FIELDS = {"benchmark_id", "benchmark_kind", "sessions_per_year"} |
47 | 50 | _SCENARIO_FIELDS = {"scenario_id", "scenario_kind", "strategy_returns_bps", "benchmark_returns_bps"} |
48 | 51 | _RECOMMENDATION_FIELDS = { |
49 | 52 | "schema", |
@@ -190,6 +193,13 @@ def calculate_risk_composer_recommendation_sha256(value: Mapping[str, Any]) -> s |
190 | 193 | ).hexdigest() |
191 | 194 |
|
192 | 195 |
|
| 196 | +def calculate_risk_observation_sha256(value: Mapping[str, Any]) -> str: |
| 197 | + """Return the stable identity of one private P3 return-path observation.""" |
| 198 | + return hashlib.sha256( |
| 199 | + _canonical_json(value, "observation_sha256", "long-horizon risk observation").encode("utf-8") |
| 200 | + ).hexdigest() |
| 201 | + |
| 202 | + |
193 | 203 | def _validate_candidate(value: Any) -> dict[str, str]: |
194 | 204 | candidate = _expect_object(value, "candidate") |
195 | 205 | _expect_exact_keys(candidate, _CANDIDATE_FIELDS, "candidate") |
@@ -234,6 +244,20 @@ def _validate_objective(value: Any) -> dict[str, Any]: |
234 | 244 | } |
235 | 245 |
|
236 | 246 |
|
| 247 | +def _validate_benchmark(value: Any) -> dict[str, Any]: |
| 248 | + benchmark = _expect_object(value, "benchmark") |
| 249 | + _expect_exact_keys(benchmark, _BENCHMARK_FIELDS, "benchmark") |
| 250 | + if benchmark["benchmark_kind"] != "unlevered_reference": |
| 251 | + _fail("benchmark.benchmark_kind must be unlevered_reference") |
| 252 | + return { |
| 253 | + "benchmark_id": _expect_identity(benchmark["benchmark_id"], "benchmark.benchmark_id"), |
| 254 | + "benchmark_kind": _expect_identity(benchmark["benchmark_kind"], "benchmark.benchmark_kind"), |
| 255 | + "sessions_per_year": _expect_positive_integer( |
| 256 | + benchmark["sessions_per_year"], "benchmark.sessions_per_year", maximum=366 |
| 257 | + ), |
| 258 | + } |
| 259 | + |
| 260 | + |
237 | 261 | def _validate_scenario(value: Any, index: int) -> dict[str, Any]: |
238 | 262 | path = f"scenario_paths[{index}]" |
239 | 263 | scenario = _expect_object(value, path) |
@@ -287,6 +311,68 @@ def validate_risk_composer_input(value: Any) -> dict[str, Any]: |
287 | 311 | return normalized |
288 | 312 |
|
289 | 313 |
|
| 314 | +def validate_long_horizon_risk_observation(value: Any) -> dict[str, Any]: |
| 315 | + """Validate a private P3 observation before an owner preference is bound. |
| 316 | +
|
| 317 | + The observation contains only frozen candidate identity, evidence digests, |
| 318 | + a same-window unlevered reference, and paired net-return paths. It is an |
| 319 | + internal ingress artifact: it is never suitable for a public console or |
| 320 | + AI prompt. Unlike a composer input it intentionally contains no risk |
| 321 | + preference, because that is a control-plane/owner decision. |
| 322 | + """ |
| 323 | + _reject_non_finite_or_null(value, "long-horizon risk observation") |
| 324 | + _reject_forbidden_material(value, "long-horizon risk observation") |
| 325 | + observation = _expect_object(value, "long-horizon risk observation") |
| 326 | + _expect_exact_keys(observation, _OBSERVATION_FIELDS, "long-horizon risk observation") |
| 327 | + if observation["schema"] != RISK_OBSERVATION_SCHEMA_ID: |
| 328 | + _fail(f"long-horizon risk observation.schema must be {RISK_OBSERVATION_SCHEMA_ID}") |
| 329 | + paths = _expect_list(observation["scenario_paths"], "observation.scenario_paths") |
| 330 | + if not paths or len(paths) > _MAX_SCENARIOS: |
| 331 | + _fail(f"observation.scenario_paths must contain between 1 and {_MAX_SCENARIOS} paths") |
| 332 | + normalized = { |
| 333 | + "schema": RISK_OBSERVATION_SCHEMA_ID, |
| 334 | + "candidate": _validate_candidate(observation["candidate"]), |
| 335 | + "source_evidence": _validate_source_evidence(observation["source_evidence"]), |
| 336 | + "benchmark": _validate_benchmark(observation["benchmark"]), |
| 337 | + "scenario_paths": [_validate_scenario(item, index) for index, item in enumerate(paths)], |
| 338 | + "observation_sha256": _expect_sha256( |
| 339 | + observation["observation_sha256"], "long-horizon risk observation.observation_sha256" |
| 340 | + ), |
| 341 | + } |
| 342 | + if len({path["scenario_id"] for path in normalized["scenario_paths"]}) != len(normalized["scenario_paths"]): |
| 343 | + _fail("observation.scenario_paths.scenario_id values must be unique") |
| 344 | + if normalized["observation_sha256"] != calculate_risk_observation_sha256(normalized): |
| 345 | + _fail("long-horizon risk observation.observation_sha256 mismatch") |
| 346 | + return normalized |
| 347 | + |
| 348 | + |
| 349 | +def build_risk_composer_input_from_observation( |
| 350 | + observation: Any, *, risk_preference: str |
| 351 | +) -> dict[str, Any]: |
| 352 | + """Attach one explicit owner preference to a frozen private observation. |
| 353 | +
|
| 354 | + This is deliberately a pure conversion. It does not refresh P3 data, |
| 355 | + choose a preference, write a policy, or authorize any lifecycle phase. |
| 356 | + """ |
| 357 | + normalized = validate_long_horizon_risk_observation(observation) |
| 358 | + objective = _validate_objective( |
| 359 | + { |
| 360 | + "risk_preference": risk_preference, |
| 361 | + **normalized["benchmark"], |
| 362 | + } |
| 363 | + ) |
| 364 | + result: dict[str, Any] = { |
| 365 | + "schema": RISK_COMPOSER_INPUT_SCHEMA_ID, |
| 366 | + "candidate": normalized["candidate"], |
| 367 | + "source_evidence": normalized["source_evidence"], |
| 368 | + "objective": objective, |
| 369 | + "scenario_paths": normalized["scenario_paths"], |
| 370 | + "input_sha256": "", |
| 371 | + } |
| 372 | + result["input_sha256"] = calculate_risk_composer_input_sha256(result) |
| 373 | + return validate_risk_composer_input(result) |
| 374 | + |
| 375 | + |
290 | 376 | def _scaled_return_bps(return_bps: int, scale_bps: int) -> int: |
291 | 377 | product = return_bps * scale_bps |
292 | 378 | return product // 10_000 if product >= 0 else -((-product + 9_999) // 10_000) |
@@ -522,13 +608,33 @@ def parse_risk_composer_input_json(text: str) -> dict[str, Any]: |
522 | 608 |
|
523 | 609 | def main(argv: list[str] | None = None) -> int: |
524 | 610 | parser = argparse.ArgumentParser(description="Compose a non-executing long-horizon risk recommendation") |
525 | | - parser.add_argument("--input", type=Path, required=True, help="frozen P3 return-path evidence JSON") |
| 611 | + input_source = parser.add_mutually_exclusive_group(required=True) |
| 612 | + input_source.add_argument("--input", type=Path, help="private, owner-bound P3 return-path evidence JSON") |
| 613 | + input_source.add_argument( |
| 614 | + "--observation", |
| 615 | + type=Path, |
| 616 | + help="private P3 observation JSON; requires an explicit --risk-preference", |
| 617 | + ) |
| 618 | + parser.add_argument( |
| 619 | + "--risk-preference", |
| 620 | + choices=tuple(sorted(_RISK_PREFERENCES)), |
| 621 | + help="owner-selected preference when converting a private observation", |
| 622 | + ) |
526 | 623 | parser.add_argument("--output", type=Path, required=True, help="advisory recommendation JSON") |
527 | 624 | args = parser.parse_args(argv) |
528 | 625 | try: |
529 | | - recommendation = compose_long_horizon_risk_recommendation( |
530 | | - parse_risk_composer_input_json(args.input.read_text(encoding="utf-8")) |
531 | | - ) |
| 626 | + if args.input is not None: |
| 627 | + if args.risk_preference is not None: |
| 628 | + _fail("--risk-preference is only valid with --observation") |
| 629 | + composer_input = parse_risk_composer_input_json(args.input.read_text(encoding="utf-8")) |
| 630 | + else: |
| 631 | + if args.risk_preference is None: |
| 632 | + _fail("--observation requires --risk-preference") |
| 633 | + composer_input = build_risk_composer_input_from_observation( |
| 634 | + parse_risk_composer_input_json(args.observation.read_text(encoding="utf-8")), |
| 635 | + risk_preference=args.risk_preference, |
| 636 | + ) |
| 637 | + recommendation = compose_long_horizon_risk_recommendation(composer_input) |
532 | 638 | validated = validate_risk_composer_recommendation(recommendation) |
533 | 639 | args.output.write_text( |
534 | 640 | json.dumps(validated, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n", |
|
0 commit comments