Skip to content

Commit ae4d64f

Browse files
committed
[act.research.ρ.D8+] bind local Qwen to governed proposals
The attachment-defined loop already bounded provider authority, but the first real RTX 5080 run exposed three production gaps: vLLM cannot grammar-compile uniqueness/property-name keywords, Qwen3.5 loops under the old low-temperature profile, and a generic proposal schema leaves current action IDs and parameter hints unconstrained. Derive a context-bound grammar subset for generation, retain the full frozen schema and semantic validator as the acceptance authority, reject catalogued actions absent from the current context, and require an exact host grant before shared-GPU profiles resolve. Real Qwen3.5-2B then completed the actual ai.research.propose Job and immutable proposal Artifact in one validation attempt.
1 parent f96ffea commit ae4d64f

12 files changed

Lines changed: 285 additions & 20 deletions

‎backend/README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,10 @@ After changing any backend file on the canonical workstation, restart
109109

110110
The provider is optional and OpenAI-compatible. Missing provider configuration leaves the
111111
core service healthy and reports the AI capability as degraded; shared-Dirac-GPU provider
112-
profiles are refused. The production loop requires forward migrations 049 and 050.
112+
profiles remain fail-closed unless the host operator sets the exact
113+
`DIRAC_ALLOW_SHARED_GPU_AI=1` grant. That grant records deployment authority only; model
114+
proposals still pass the frozen schema, current-action and scientific-evidence validators.
115+
The production loop requires forward migrations 049 and 050.
113116

114117
```bash
115118
bash scripts/gates.sh research

‎backend/research/openai_compatible.py‎

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import urllib.error
66
import urllib.request
77
from dataclasses import dataclass
8-
from typing import Any, Callable
8+
from typing import Any, Callable, Mapping
99

1010
from .provider_registry import (
1111
AiProviderConfigurationError,
@@ -17,6 +17,9 @@
1717

1818
RETRYABLE_HTTP_STATUS = frozenset({429, 500, 502, 503, 504})
1919
MAX_RETRY_AFTER_SECONDS = 10.0
20+
GENERATION_UNSUPPORTED_SCHEMA_KEYS = frozenset({"propertyNames", "uniqueItems"})
21+
GENERATION_MAX_ITEMS = 1
22+
GENERATION_MAX_TEXT_LENGTH = 512
2023

2124

2225
class ProviderUnavailable(RuntimeError):
@@ -42,6 +45,27 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001
4245
return None
4346

4447

48+
def _generation_schema(value: Any) -> Any:
49+
"""Keep grammar-expressible constraints; DIRAC still validates the frozen schema."""
50+
51+
if isinstance(value, Mapping):
52+
result = {
53+
key: _generation_schema(item)
54+
for key, item in value.items()
55+
if key not in GENERATION_UNSUPPORTED_SCHEMA_KEYS
56+
}
57+
if isinstance(result.get("maxItems"), int):
58+
result["maxItems"] = min(result["maxItems"], GENERATION_MAX_ITEMS)
59+
if isinstance(result.get("maxLength"), int):
60+
result["maxLength"] = min(
61+
result["maxLength"], GENERATION_MAX_TEXT_LENGTH
62+
)
63+
return result
64+
if isinstance(value, list):
65+
return [_generation_schema(item) for item in value]
66+
return value
67+
68+
4569
@dataclass(frozen=True)
4670
class ProviderChatResult:
4771
content: str
@@ -93,17 +117,32 @@ def _retry_delay(headers: Any, attempt: int) -> float:
93117

94118
@staticmethod
95119
def _request_body(
96-
profile: ResolvedProviderProfile, system_prompt: str, context_json: str
120+
profile: ResolvedProviderProfile, system_prompt: str, context_json: str,
121+
output_schema: Mapping[str, Any] | None,
97122
) -> bytes:
98123
if "JSON" not in system_prompt.upper() or "JSON" not in context_json.upper():
99124
raise ModelOutputInvalid("messages_must_explicitly_request_json")
125+
response_mode = str(profile.document["response_mode"])
126+
if response_mode == "json_schema":
127+
if not isinstance(output_schema, Mapping):
128+
raise ModelOutputInvalid("json_schema_profile_requires_output_schema")
129+
response_format: dict[str, Any] = {
130+
"type": "json_schema",
131+
"json_schema": {
132+
"name": "dirac_research_proposal",
133+
"strict": True,
134+
"schema": _generation_schema(output_schema),
135+
},
136+
}
137+
else:
138+
response_format = {"type": "json_object"}
100139
body: dict[str, Any] = {
101140
"model": profile.configured_model,
102141
"messages": [
103142
{"role": "system", "content": system_prompt},
104143
{"role": "user", "content": context_json},
105144
],
106-
"response_format": {"type": "json_object"},
145+
"response_format": response_format,
107146
"stream": False,
108147
}
109148
body.update(dict(profile.document["static_request_fields"]))
@@ -158,12 +197,14 @@ def complete_json(
158197
*,
159198
system_prompt: str,
160199
context_json: str,
200+
output_schema: Mapping[str, Any] | None = None,
161201
) -> ProviderChatResult:
162202
started = self._monotonic()
163203
attempts = 0
164204
try:
165205
result = self._complete_json(
166-
profile, system_prompt=system_prompt, context_json=context_json)
206+
profile, system_prompt=system_prompt, context_json=context_json,
207+
output_schema=output_schema)
167208
attempts = result.attempts
168209
return result
169210
except (ProviderUnavailable, ModelOutputInvalid) as error:
@@ -187,9 +228,11 @@ def _complete_json(
187228
*,
188229
system_prompt: str,
189230
context_json: str,
231+
output_schema: Mapping[str, Any] | None,
190232
) -> ProviderChatResult:
191233
endpoint = self._endpoint(profile)
192-
payload = self._request_body(profile, system_prompt, context_json)
234+
payload = self._request_body(
235+
profile, system_prompt, context_json, output_schema)
193236
max_request = int(profile.document["bounds"]["max_request_bytes"])
194237
max_response = int(profile.document["bounds"]["max_response_bytes"])
195238
if len(payload) > max_request:

‎backend/research/proposal_validator.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,12 @@ def validate_proposal(
189189
facts = {item["fact_id"]: item for item in context["facts"]}
190190
object_refs = {_ref_key(item["ref"]) for item in context["objects"]}
191191
object_refs.update(_ref_key(item["subject_ref"]) for item in context["facts"])
192+
available_action_refs: set[tuple[str, tuple[str, str]]] = set()
192193
for action in context["available_actions"]:
193194
object_refs.update(_ref_key(ref) for ref in action["subject_refs"])
195+
available_action_refs.update(
196+
(action["template_id"], _ref_key(ref)) for ref in action["subject_refs"]
197+
)
194198

195199
stale_fact_ids = {key for key, fact in facts.items() if fact["freshness"]["stale"]}
196200
for fact_id, pointer, limitation_text in _fact_references(document):
@@ -238,6 +242,14 @@ def validate_proposal(
238242
"proposal_references_unknown_subject",
239243
pointer=("candidate_actions", index, "subject_ref"),
240244
)
245+
action_availability_key = (
246+
action["template_id"], _ref_key(action["subject_ref"])
247+
)
248+
if action_availability_key not in available_action_refs:
249+
raise ProposalValidationError(
250+
"proposal_action_is_not_currently_available",
251+
pointer=("candidate_actions", index, "template_id"),
252+
)
241253
template = action_catalog.get(action["template_id"])
242254
if template is None:
243255
raise ProposalValidationError(

‎backend/research/provider_registry.py‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
)
2020
CONFIG_ENV = "DIRAC_AI_PROVIDER_CONFIG"
2121
ALLOWLIST_ENV = "DIRAC_AI_PROVIDER_HOST_ALLOWLIST"
22+
SHARED_GPU_GRANT_ENV = "DIRAC_ALLOW_SHARED_GPU_AI"
2223

2324

2425
class AiProviderConfigurationError(ValueError):
@@ -215,13 +216,16 @@ def _resolve_document(self, profile_id: str) -> tuple[dict[str, Any], str, str]:
215216
raise AiProviderConfigurationError(
216217
"provider_profile_not_found", details={"profile_id": profile_id}
217218
)
218-
if document["resource_isolation"] == "shared_dirac_gpu":
219+
if (
220+
document["resource_isolation"] == "shared_dirac_gpu"
221+
and self._environ.get(SHARED_GPU_GRANT_ENV) != "1"
222+
):
219223
raise AiProviderConfigurationError(
220-
"shared_gpu_provider_not_supported_in_v0",
224+
"shared_gpu_provider_requires_explicit_grant",
221225
details={
222-
"reason": "shared_gpu_provider_not_supported_in_v0",
226+
"reason": "shared_gpu_provider_requires_explicit_grant",
223227
"recovery": (
224-
"use cloud inference or a separately isolated model endpoint"
228+
"use an isolated endpoint or set the host-scoped shared GPU grant"
225229
),
226230
},
227231
)

‎backend/research/reasoner.py‎

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import copy
34
import hashlib
45
import json
56
import pathlib
@@ -65,6 +66,97 @@ def _prompt_release() -> tuple[dict[str, Any], str, str]:
6566
return manifest, sha256_digest(manifest), system_prompt
6667

6768

69+
def build_generation_schema(
70+
context: Mapping[str, Any], catalog: Mapping[str, Mapping[str, Any]]
71+
) -> dict[str, Any]:
72+
"""Bind provider grammar to IDs and action shapes in the frozen context."""
73+
74+
try:
75+
schema = json.loads(PROPOSAL_SCHEMA_PATH.read_text(encoding="utf-8"))
76+
except (OSError, json.JSONDecodeError) as error:
77+
raise failures.DiracInternal(
78+
f"research proposal contract cannot be loaded: {type(error).__name__}"
79+
) from None
80+
81+
schema["properties"]["context_digest"] = {"const": context["digest"]}
82+
singleton_ids = {
83+
"hypothesis_drafts": ("hypothesis_id", "h1"),
84+
"claim_assessments": ("claim_id", "c1"),
85+
"scientific_questions": ("question_id", "q1"),
86+
"candidate_actions": ("proposal_action_id", "a1"),
87+
}
88+
for section, (field, value) in singleton_ids.items():
89+
section_schema = schema["properties"][section]
90+
section_schema["minItems"] = 1
91+
section_schema["items"]["properties"][field] = {"const": value}
92+
schema["properties"]["candidate_actions"]["items"]["properties"][
93+
"scientific_question_id"
94+
] = {"const": "q1"}
95+
schema["properties"]["preferred_action_id"] = {"const": "a1"}
96+
97+
has_eligible_evidence = any(
98+
item["source_class"] == "typed_evidence"
99+
and item["claim_boundary"]["eligible_as_scientific_evidence"]
100+
for item in context["facts"]
101+
)
102+
if not has_eligible_evidence:
103+
schema["properties"]["claim_assessments"]["items"]["properties"][
104+
"interpretation"
105+
] = {"const": "unresolved"}
106+
fact_ids = sorted({str(item["fact_id"]) for item in context["facts"]})
107+
fact_id_schema = schema["$defs"]["factIds"]
108+
if fact_ids:
109+
fact_id_schema["items"] = {"enum": fact_ids}
110+
else:
111+
fact_id_schema["maxItems"] = 0
112+
113+
refs: dict[tuple[str, str], dict[str, str]] = {}
114+
for item in context["objects"]:
115+
ref = item["ref"]
116+
refs[(ref["kind"], ref["id"])] = dict(ref)
117+
for item in context["facts"]:
118+
ref = item["subject_ref"]
119+
refs[(ref["kind"], ref["id"])] = dict(ref)
120+
for available in context["available_actions"]:
121+
for ref in available["subject_refs"]:
122+
refs[(ref["kind"], ref["id"])] = dict(ref)
123+
subject_refs = [refs[key] for key in sorted(refs)]
124+
if subject_refs:
125+
schema["properties"]["scientific_questions"]["items"]["properties"][
126+
"subject_ref"
127+
] = {"enum": subject_refs}
128+
129+
candidate = schema["properties"]["candidate_actions"]["items"]
130+
branches: list[dict[str, Any]] = []
131+
for available in context["available_actions"]:
132+
template_id = str(available["template_id"])
133+
template = catalog.get(template_id)
134+
if template is None:
135+
raise failures.DiracInternal(
136+
f"research context exposes unknown action template {template_id}"
137+
)
138+
for subject_ref in available["subject_refs"]:
139+
branch = copy.deepcopy(candidate)
140+
branch["properties"]["proposal_action_id"] = {"const": "a1"}
141+
branch["properties"]["scientific_question_id"] = {"const": "q1"}
142+
branch["properties"]["template_id"] = {"const": template_id}
143+
branch["properties"]["subject_ref"] = {"const": dict(subject_ref)}
144+
hints = copy.deepcopy(template["model_hint_schema"])
145+
edge = hints.get("properties", {}).get("edge_id")
146+
if (
147+
isinstance(edge, dict)
148+
and subject_ref["kind"] == "free_energy_transformation"
149+
):
150+
hints["properties"]["edge_id"] = {"const": subject_ref["id"]}
151+
branch["properties"]["parameter_hints"] = hints
152+
branches.append(branch)
153+
if not branches:
154+
schema["properties"]["candidate_actions"]["maxItems"] = 0
155+
else:
156+
schema["properties"]["candidate_actions"]["items"] = {"oneOf": branches}
157+
return schema
158+
159+
68160
def _read_context(payload: Mapping[str, Any], ctx: InvocationContext) -> dict[str, Any]:
69161
if ctx.artifact_reader is None:
70162
raise failures.DiracUnsupported(
@@ -244,6 +336,7 @@ def propose_handler(payload: dict, ctx: InvocationContext) -> HandlerResult:
244336
details={"reason": error.reason, **error.details},
245337
) from None
246338
provider = OpenAICompatibleChatProvider()
339+
output_schema = build_generation_schema(context, catalog)
247340
maximum_regenerations = int(profile.document["bounds"]["max_schema_regenerations"])
248341
validation_error: dict[str, Any] | None = None
249342
usage: dict[str, Any] = {}
@@ -257,7 +350,8 @@ def propose_handler(payload: dict, ctx: InvocationContext) -> HandlerResult:
257350
)
258351
try:
259352
result = provider.complete_json(
260-
profile, system_prompt=system, context_json=user
353+
profile, system_prompt=system, context_json=user,
354+
output_schema=output_schema,
261355
)
262356
except ProviderUnavailable as error:
263357
raise failures.DiracProviderUnavailable(

‎backend/tests/test_ai_provider_registry.py‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,28 @@ def test_unconfigured_profile_is_degraded_not_absent(self):
6262
self.assertFalse(public[0]["configured"])
6363
self.assertEqual(public[0]["reason"], "provider_profile_is_unconfigured")
6464

65-
def test_shared_gpu_profile_is_rejected_in_v0(self):
65+
def test_shared_gpu_profile_is_rejected_without_explicit_host_grant(self):
6666
self.profile["resource_isolation"] = "shared_dirac_gpu"
6767
registry = _registry(self.profile, self.environ)
6868
with self.assertRaisesRegex(
69-
AiProviderConfigurationError, "shared_gpu_provider_not_supported_in_v0"
69+
AiProviderConfigurationError, "shared_gpu_provider_requires_explicit_grant"
7070
) as caught:
7171
registry.resolve("qwen-local-isolated")
72-
self.assertIn("separately isolated", caught.exception.details["recovery"])
72+
self.assertIn("host-scoped", caught.exception.details["recovery"])
73+
74+
def test_shared_gpu_profile_resolves_only_with_exact_host_grant(self):
75+
self.profile["resource_isolation"] = "shared_dirac_gpu"
76+
denied = {**self.environ, "DIRAC_ALLOW_SHARED_GPU_AI": "true"}
77+
with self.assertRaisesRegex(
78+
AiProviderConfigurationError, "shared_gpu_provider_requires_explicit_grant"
79+
):
80+
_registry(self.profile, denied).resolve("qwen-local-isolated")
81+
82+
granted = {**self.environ, "DIRAC_ALLOW_SHARED_GPU_AI": "1"}
83+
resolved = _registry(self.profile, granted).resolve("qwen-local-isolated")
84+
self.assertEqual(
85+
resolved.to_provenance()["resource_isolation"], "shared_dirac_gpu"
86+
)
7387

7488
def test_profile_schema_rejects_arbitrary_static_request_fields(self):
7589
self.profile["static_request_fields"]["tools"] = []

0 commit comments

Comments
 (0)