commit d04a0ddf77b6e96f107bcd174df46d2df929d25b
parent d03bebf7ce20a4390d84c47819b202633b9ab799
Author: triesap <tyson@radroots.org>
Date: Mon, 21 Sep 2026 13:05:36 +0000
provider: validate answer sets and numerical consistency
Diffstat:
2 files changed, 71 insertions(+), 0 deletions(-)
diff --git a/src/hyf_provider/jev_answers.mojo b/src/hyf_provider/jev_answers.mojo
@@ -104,3 +104,38 @@ def parse_score_answer(
if total < 0.999 or total > 1.001:
raise Error("provider_answer_bad_distribution_sum")
return typed_score(question_id, score, len(rubric), confidence)
+
+
+from hyf_assist.questions import QuestionBundle
+
+
+def parse_jev_response(
+ body: Value, bundle: QuestionBundle
+) raises -> List[TypedAnswer]:
+ if not body.is_object():
+ raise Error("provider_response_invalid")
+ if not _has_key(body, "model") or not _has_key(body, "answers"):
+ raise Error("provider_response_invalid")
+ if body["model"].string_value() != bundle.model:
+ raise Error("provider_model_mismatch")
+ var answers = body["answers"]
+ if not answers.is_object():
+ raise Error("provider_response_invalid")
+
+ var result = List[TypedAnswer]()
+ for question in bundle.questions:
+ if not _has_key(answers, question.id):
+ raise Error("provider_answer_missing")
+ var answer = answers[question.id]
+ if question.kind == "choice":
+ result.append(parse_choice_answer(question.id, answer, question.choices))
+ elif question.kind == "noul":
+ result.append(parse_noul_answer(question.id, answer))
+ elif question.kind == "score":
+ result.append(parse_score_answer(question.id, answer, question.rubric))
+ else:
+ raise Error("provider_answer_wrong_type")
+
+ if len(answers.object_keys()) != len(bundle.questions):
+ raise Error("provider_answer_extra")
+ return result^
diff --git a/tests/test_jev.mojo b/tests/test_jev.mojo
@@ -108,3 +108,39 @@ def test_parse_score_answer_validates_rubric_and_levels() raises:
loads('{"type":"score","score":1,"legend":{"0":"a"},"confidence":1.0}'),
rubric,
)
+
+
+from hyf_provider.jev_answers import parse_jev_response
+
+
+def test_parse_jev_response_validates_answer_set() raises:
+ var body = loads(
+ '{"model":"jev-1.13.0","answers":{'
+ '"supply_status":{"type":"choice","choice":"offered","probabilities":{"offered":1.0,"forecast":0.0,"unclear":0.0},"confidence":1.0},'
+ '"seconds_ok":{"type":"noul","noul":0.9},'
+ '"culinary_fit":{"type":"score","score":2,"legend":{"0":"u","1":"l","2":"s"},"probabilities":{"0":0.0,"1":0.0,"2":1.0},"confidence":1.0}},'
+ '"usage":{"input_tokens":10,"output_tokens":5}}'
+ )
+ var answers = parse_jev_response(body, _bundle())
+ assert_equal(len(answers), 3)
+
+ var extra = loads(
+ '{"model":"jev-1.13.0","answers":{'
+ '"supply_status":{"type":"choice","choice":"offered","probabilities":{"offered":1.0,"forecast":0.0,"unclear":0.0},"confidence":1.0},'
+ '"seconds_ok":{"type":"noul","noul":0.9},'
+ '"culinary_fit":{"type":"score","score":2,"legend":{"0":"u","1":"l","2":"s"},"probabilities":{"0":0.0,"1":0.0,"2":1.0},"confidence":1.0},'
+ '"extra":{"type":"noul","noul":0.5}},'
+ '"usage":{"input_tokens":10,"output_tokens":5}}'
+ )
+ with assert_raises():
+ _ = parse_jev_response(extra, _bundle())
+
+ var mismatch = loads(
+ '{"model":"jev-other","answers":{"supply_status":{"type":"choice","choice":"offered","confidence":1.0},"seconds_ok":{"type":"noul","noul":0.9},"culinary_fit":{"type":"score","score":2,"confidence":1.0}}}'
+ )
+ with assert_raises():
+ _ = parse_jev_response(mismatch, _bundle())
+
+ var missing = loads('{"model":"jev-1.13.0","answers":{"supply_status":{"type":"choice","choice":"offered","confidence":1.0}}}')
+ with assert_raises():
+ _ = parse_jev_response(missing, _bundle())