2929
3030RISK_COMPOSER_INPUT_SCHEMA_ID = "qsl.long_horizon_risk_composer_input.v1"
3131RISK_COMPOSER_RECOMMENDATION_SCHEMA_ID = "qsl.long_horizon_risk_composer_recommendation.v1"
32+ RISK_OBSERVATION_SCHEMA_ID = "qsl.long_horizon_risk_observation.v1"
3233_IDENTITY_PATTERN = re .compile (r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" )
3334_REPOSITORY_PATTERN = re .compile (r"^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$" )
3435_REVISION_PATTERN = re .compile (r"^[0-9a-f]{40}$" )
4142 re .IGNORECASE ,
4243)
4344_INPUT_FIELDS = {"schema" , "candidate" , "source_evidence" , "objective" , "scenario_paths" , "input_sha256" }
45+ _OBSERVATION_FIELDS = {"schema" , "candidate" , "source_evidence" , "benchmark" , "scenario_paths" , "observation_sha256" }
4446_CANDIDATE_FIELDS = {"candidate_id" , "candidate_kind" , "strategy_repository" , "strategy_revision" }
4547_SOURCE_EVIDENCE_FIELDS = {"p1_input_digest" , "p2_config_digest" , "p3_evidence_sha256" , "plugin_bundle_sha256" }
4648_OBJECTIVE_FIELDS = {"risk_preference" , "benchmark_id" , "benchmark_kind" , "sessions_per_year" }
47- _SCENARIO_FIELDS = {"scenario_id" , "scenario_kind" , "strategy_returns_bps" , "benchmark_returns_bps" }
49+ _BENCHMARK_FIELDS = {"benchmark_id" , "benchmark_kind" , "sessions_per_year" }
50+ _SCENARIO_FIELDS = {
51+ "scenario_id" ,
52+ "scenario_kind" ,
53+ "session_count" ,
54+ "strategy_returns_bps" ,
55+ "benchmark_returns_bps" ,
56+ }
4857_RECOMMENDATION_FIELDS = {
4958 "schema" ,
5059 "candidate" ,
@@ -190,6 +199,13 @@ def calculate_risk_composer_recommendation_sha256(value: Mapping[str, Any]) -> s
190199 ).hexdigest ()
191200
192201
202+ def calculate_risk_observation_sha256 (value : Mapping [str , Any ]) -> str :
203+ """Return the stable identity of one private P3 return-path observation."""
204+ return hashlib .sha256 (
205+ _canonical_json (value , "observation_sha256" , "long-horizon risk observation" ).encode ("utf-8" )
206+ ).hexdigest ()
207+
208+
193209def _validate_candidate (value : Any ) -> dict [str , str ]:
194210 candidate = _expect_object (value , "candidate" )
195211 _expect_exact_keys (candidate , _CANDIDATE_FIELDS , "candidate" )
@@ -234,6 +250,20 @@ def _validate_objective(value: Any) -> dict[str, Any]:
234250 }
235251
236252
253+ def _validate_benchmark (value : Any ) -> dict [str , Any ]:
254+ benchmark = _expect_object (value , "benchmark" )
255+ _expect_exact_keys (benchmark , _BENCHMARK_FIELDS , "benchmark" )
256+ if benchmark ["benchmark_kind" ] != "unlevered_reference" :
257+ _fail ("benchmark.benchmark_kind must be unlevered_reference" )
258+ return {
259+ "benchmark_id" : _expect_identity (benchmark ["benchmark_id" ], "benchmark.benchmark_id" ),
260+ "benchmark_kind" : _expect_identity (benchmark ["benchmark_kind" ], "benchmark.benchmark_kind" ),
261+ "sessions_per_year" : _expect_positive_integer (
262+ benchmark ["sessions_per_year" ], "benchmark.sessions_per_year" , maximum = 366
263+ ),
264+ }
265+
266+
237267def _validate_scenario (value : Any , index : int ) -> dict [str , Any ]:
238268 path = f"scenario_paths[{ index } ]"
239269 scenario = _expect_object (value , path )
@@ -245,11 +275,17 @@ def _validate_scenario(value: Any, index: int) -> dict[str, Any]:
245275 benchmark_returns = _expect_list (scenario ["benchmark_returns_bps" ], f"{ path } .benchmark_returns_bps" )
246276 if len (strategy_returns ) != len (benchmark_returns ):
247277 _fail (f"{ path } strategy and benchmark returns must have the same length" )
248- if len (strategy_returns ) > _MAX_SESSIONS_PER_SCENARIO :
278+ session_count = _expect_positive_integer (
279+ scenario ["session_count" ], f"{ path } .session_count" , maximum = _MAX_SESSIONS_PER_SCENARIO
280+ )
281+ if len (strategy_returns ) != session_count - 1 :
282+ _fail (f"{ path } must contain exactly one fewer return than its observed sessions" )
283+ if len (strategy_returns ) > _MAX_SESSIONS_PER_SCENARIO - 1 :
249284 _fail (f"{ path } exceeds the bounded session count" )
250285 return {
251286 "scenario_id" : _expect_identity (scenario ["scenario_id" ], f"{ path } .scenario_id" ),
252287 "scenario_kind" : kind ,
288+ "session_count" : session_count ,
253289 "strategy_returns_bps" : [
254290 _expect_return_bps (item , f"{ path } .strategy_returns_bps[{ return_index } ]" )
255291 for return_index , item in enumerate (strategy_returns )
@@ -287,6 +323,68 @@ def validate_risk_composer_input(value: Any) -> dict[str, Any]:
287323 return normalized
288324
289325
326+ def validate_long_horizon_risk_observation (value : Any ) -> dict [str , Any ]:
327+ """Validate a private P3 observation before an owner preference is bound.
328+
329+ The observation contains only frozen candidate identity, evidence digests,
330+ a same-window unlevered reference, and paired net-return paths. It is an
331+ internal ingress artifact: it is never suitable for a public console or
332+ AI prompt. Unlike a composer input it intentionally contains no risk
333+ preference, because that is a control-plane/owner decision.
334+ """
335+ _reject_non_finite_or_null (value , "long-horizon risk observation" )
336+ _reject_forbidden_material (value , "long-horizon risk observation" )
337+ observation = _expect_object (value , "long-horizon risk observation" )
338+ _expect_exact_keys (observation , _OBSERVATION_FIELDS , "long-horizon risk observation" )
339+ if observation ["schema" ] != RISK_OBSERVATION_SCHEMA_ID :
340+ _fail (f"long-horizon risk observation.schema must be { RISK_OBSERVATION_SCHEMA_ID } " )
341+ paths = _expect_list (observation ["scenario_paths" ], "observation.scenario_paths" )
342+ if not paths or len (paths ) > _MAX_SCENARIOS :
343+ _fail (f"observation.scenario_paths must contain between 1 and { _MAX_SCENARIOS } paths" )
344+ normalized = {
345+ "schema" : RISK_OBSERVATION_SCHEMA_ID ,
346+ "candidate" : _validate_candidate (observation ["candidate" ]),
347+ "source_evidence" : _validate_source_evidence (observation ["source_evidence" ]),
348+ "benchmark" : _validate_benchmark (observation ["benchmark" ]),
349+ "scenario_paths" : [_validate_scenario (item , index ) for index , item in enumerate (paths )],
350+ "observation_sha256" : _expect_sha256 (
351+ observation ["observation_sha256" ], "long-horizon risk observation.observation_sha256"
352+ ),
353+ }
354+ if len ({path ["scenario_id" ] for path in normalized ["scenario_paths" ]}) != len (normalized ["scenario_paths" ]):
355+ _fail ("observation.scenario_paths.scenario_id values must be unique" )
356+ if normalized ["observation_sha256" ] != calculate_risk_observation_sha256 (normalized ):
357+ _fail ("long-horizon risk observation.observation_sha256 mismatch" )
358+ return normalized
359+
360+
361+ def build_risk_composer_input_from_observation (
362+ observation : Any , * , risk_preference : str
363+ ) -> dict [str , Any ]:
364+ """Attach one explicit owner preference to a frozen private observation.
365+
366+ This is deliberately a pure conversion. It does not refresh P3 data,
367+ choose a preference, write a policy, or authorize any lifecycle phase.
368+ """
369+ normalized = validate_long_horizon_risk_observation (observation )
370+ objective = _validate_objective (
371+ {
372+ "risk_preference" : risk_preference ,
373+ ** normalized ["benchmark" ],
374+ }
375+ )
376+ result : dict [str , Any ] = {
377+ "schema" : RISK_COMPOSER_INPUT_SCHEMA_ID ,
378+ "candidate" : normalized ["candidate" ],
379+ "source_evidence" : normalized ["source_evidence" ],
380+ "objective" : objective ,
381+ "scenario_paths" : normalized ["scenario_paths" ],
382+ "input_sha256" : "" ,
383+ }
384+ result ["input_sha256" ] = calculate_risk_composer_input_sha256 (result )
385+ return validate_risk_composer_input (result )
386+
387+
290388def _scaled_return_bps (return_bps : int , scale_bps : int ) -> int :
291389 product = return_bps * scale_bps
292390 return product // 10_000 if product >= 0 else - ((- product + 9_999 ) // 10_000 )
@@ -368,7 +466,7 @@ def compose_long_horizon_risk_recommendation(value: Any) -> dict[str, Any]:
368466 reasons : list [str ] = []
369467 if kinds != _SCENARIO_KINDS :
370468 reasons .append ("SCENARIO_KIND_COVERAGE_INCOMPLETE" )
371- if any (len ( path ["strategy_returns_bps" ]) < _MIN_SESSIONS_PER_SCENARIO for path in paths ):
469+ if any (path ["session_count" ] < _MIN_SESSIONS_PER_SCENARIO for path in paths ):
372470 reasons .append ("LONG_HORIZON_SESSION_COVERAGE_INCOMPLETE" )
373471 if reasons :
374472 return _parked_recommendation (validated , reasons )
@@ -522,13 +620,33 @@ def parse_risk_composer_input_json(text: str) -> dict[str, Any]:
522620
523621def main (argv : list [str ] | None = None ) -> int :
524622 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" )
623+ input_source = parser .add_mutually_exclusive_group (required = True )
624+ input_source .add_argument ("--input" , type = Path , help = "private, owner-bound P3 return-path evidence JSON" )
625+ input_source .add_argument (
626+ "--observation" ,
627+ type = Path ,
628+ help = "private P3 observation JSON; requires an explicit --risk-preference" ,
629+ )
630+ parser .add_argument (
631+ "--risk-preference" ,
632+ choices = tuple (sorted (_RISK_PREFERENCES )),
633+ help = "owner-selected preference when converting a private observation" ,
634+ )
526635 parser .add_argument ("--output" , type = Path , required = True , help = "advisory recommendation JSON" )
527636 args = parser .parse_args (argv )
528637 try :
529- recommendation = compose_long_horizon_risk_recommendation (
530- parse_risk_composer_input_json (args .input .read_text (encoding = "utf-8" ))
531- )
638+ if args .input is not None :
639+ if args .risk_preference is not None :
640+ _fail ("--risk-preference is only valid with --observation" )
641+ composer_input = parse_risk_composer_input_json (args .input .read_text (encoding = "utf-8" ))
642+ else :
643+ if args .risk_preference is None :
644+ _fail ("--observation requires --risk-preference" )
645+ composer_input = build_risk_composer_input_from_observation (
646+ parse_risk_composer_input_json (args .observation .read_text (encoding = "utf-8" )),
647+ risk_preference = args .risk_preference ,
648+ )
649+ recommendation = compose_long_horizon_risk_recommendation (composer_input )
532650 validated = validate_risk_composer_recommendation (recommendation )
533651 args .output .write_text (
534652 json .dumps (validated , sort_keys = True , separators = ("," , ":" ), ensure_ascii = True ) + "\n " ,
0 commit comments