1use crate::error::{KernelError, Result};
19use crate::secrets::contains_secret;
20use crate::trace::{PredictionRecord, Trace, TraceKind, MODEL_SOURCE_PREFIX};
21use serde::{Deserialize, Serialize};
22use std::collections::{BTreeMap, BTreeSet};
23use std::time::Instant;
24
25pub const MAX_QUESTIONS: usize = 32;
26pub const MAX_STATE_CHARS: usize = 32_000;
27fn sum_tolerance(keys: usize) -> f64 {
30 (0.005 * keys as f64).max(0.02) + 1e-9
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34#[serde(tag = "type", rename_all = "snake_case")]
35pub enum QuestionKind {
36 Choice { options: BTreeMap<String, String> },
38 Score { levels: Vec<String> },
40 Noul {
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 if_true: Option<String>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 if_false: Option<String>,
46 },
47}
48
49impl QuestionKind {
50 fn name(&self) -> &'static str {
51 match self {
52 Self::Choice { .. } => "choice",
53 Self::Score { .. } => "score",
54 Self::Noul { .. } => "noul",
55 }
56 }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60pub struct Question {
61 pub id: String,
62 pub instructions: String,
63 #[serde(flatten)]
64 pub kind: QuestionKind,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub family: Option<String>,
68}
69
70impl Question {
71 pub fn noul(id: impl Into<String>, instructions: impl Into<String>) -> Self {
72 Self {
73 id: id.into(),
74 instructions: instructions.into(),
75 kind: QuestionKind::Noul {
76 if_true: None,
77 if_false: None,
78 },
79 family: None,
80 }
81 }
82
83 pub fn family(&self) -> &str {
84 self.family.as_deref().unwrap_or(&self.id)
85 }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
89pub struct DecisionRequest {
90 pub state: String,
91 pub questions: Vec<Question>,
92}
93
94impl DecisionRequest {
95 pub fn new(state: impl Into<String>, questions: Vec<Question>) -> Result<Self> {
96 let request = Self {
97 state: state.into(),
98 questions,
99 };
100 request.validate()?;
101 Ok(request)
102 }
103
104 pub fn validate(&self) -> Result<()> {
105 if self.state.trim().is_empty() {
106 return Err(KernelError::Invalid(
107 "decision state cannot be empty".into(),
108 ));
109 }
110 if self.state.chars().count() > MAX_STATE_CHARS {
111 return Err(KernelError::Invalid(format!(
112 "decision state exceeds {MAX_STATE_CHARS} characters"
113 )));
114 }
115 if self.questions.is_empty() || self.questions.len() > MAX_QUESTIONS {
116 return Err(KernelError::Invalid(format!(
117 "a decision request needs 1..={MAX_QUESTIONS} questions"
118 )));
119 }
120 let mut ids = BTreeSet::new();
121 for question in &self.questions {
122 let valid_id = !question.id.is_empty()
123 && question.id.len() <= 64
124 && question
125 .id
126 .chars()
127 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
128 if !valid_id {
129 return Err(KernelError::Invalid(format!(
130 "question id `{}` must be 1-64 chars of [A-Za-z0-9_-]",
131 question.id
132 )));
133 }
134 if !ids.insert(question.id.as_str()) {
135 return Err(KernelError::Invalid(format!(
136 "duplicate question id: {}",
137 question.id
138 )));
139 }
140 if question.instructions.trim().is_empty() {
141 return Err(KernelError::Invalid(format!(
142 "question {} needs instructions",
143 question.id
144 )));
145 }
146 match &question.kind {
147 QuestionKind::Choice { options } => {
148 if !(2..=255).contains(&options.len()) {
149 return Err(KernelError::Invalid(format!(
150 "choice question {} needs 2..=255 options",
151 question.id
152 )));
153 }
154 if options.keys().any(|k| k.trim().is_empty()) {
155 return Err(KernelError::Invalid(format!(
156 "choice question {} has an empty option id",
157 question.id
158 )));
159 }
160 }
161 QuestionKind::Score { levels } => {
162 if !(2..=10).contains(&levels.len()) {
163 return Err(KernelError::Invalid(format!(
164 "score question {} needs 2..=10 levels",
165 question.id
166 )));
167 }
168 }
169 QuestionKind::Noul { if_true, if_false } => {
170 if if_true.is_some() != if_false.is_some() {
171 return Err(KernelError::Invalid(format!(
172 "noul question {} needs both if_true and if_false, or neither",
173 question.id
174 )));
175 }
176 }
177 }
178 }
179 Ok(())
180 }
181
182 pub fn outbound_texts(&self) -> Vec<&str> {
184 let mut texts = vec![self.state.as_str()];
185 for question in &self.questions {
186 texts.push(&question.instructions);
187 if let Some(family) = &question.family {
188 texts.push(family);
189 }
190 match &question.kind {
191 QuestionKind::Choice { options } => {
192 for (id, meaning) in options {
193 texts.push(id);
194 texts.push(meaning);
195 }
196 }
197 QuestionKind::Score { levels } => texts.extend(levels.iter().map(String::as_str)),
198 QuestionKind::Noul { if_true, if_false } => {
199 texts.extend(if_true.as_deref());
200 texts.extend(if_false.as_deref());
201 }
202 }
203 }
204 texts
205 }
206
207 pub fn request_id(&self) -> String {
209 let canonical = serde_json::to_vec(self).unwrap_or_default();
210 let hash = blake3::hash(&canonical).to_hex().to_string();
211 format!("req_{}", &hash[..16])
212 }
213
214 pub fn question(&self, id: &str) -> Option<&Question> {
215 self.questions.iter().find(|q| q.id == id)
216 }
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
220pub struct EngineDescriptor {
221 pub name: String,
222 pub version: String,
223}
224
225impl EngineDescriptor {
226 pub fn identity(&self) -> String {
230 format!("{}@{}", self.name.trim(), self.version.trim())
231 }
232
233 pub fn source(&self) -> String {
235 format!("{MODEL_SOURCE_PREFIX}{}", self.identity())
236 }
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
242#[serde(tag = "type", rename_all = "snake_case")]
243pub enum RawAnswer {
244 Choice {
245 choice: String,
246 #[serde(default)]
247 probabilities: Option<BTreeMap<String, f64>>,
248 #[serde(default)]
249 confidence: Option<f64>,
250 },
251 Score {
252 score: f64,
253 #[serde(default)]
254 probabilities: Option<BTreeMap<String, f64>>,
255 #[serde(default)]
256 confidence: Option<f64>,
257 },
258 Noul {
259 noul: f64,
260 },
261 Abstain {
262 reason: String,
263 },
264 Malformed {
266 detail: String,
267 },
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
271pub struct RawDecision {
272 pub request_id: String,
273 pub engine: EngineDescriptor,
274 pub answers: BTreeMap<String, RawAnswer>,
275 pub latency_ms: u64,
276}
277
278pub trait DecisionEngine {
280 fn descriptor(&self) -> EngineDescriptor;
281 fn decide(&self, request: &DecisionRequest) -> Result<RawDecision>;
282}
283
284#[derive(Debug, Default, Clone, Copy)]
286pub struct NoEngine;
287
288impl DecisionEngine for NoEngine {
289 fn descriptor(&self) -> EngineDescriptor {
290 EngineDescriptor {
291 name: "none".into(),
292 version: "0".into(),
293 }
294 }
295
296 fn decide(&self, request: &DecisionRequest) -> Result<RawDecision> {
297 Ok(RawDecision {
298 request_id: request.request_id(),
299 engine: self.descriptor(),
300 answers: request
301 .questions
302 .iter()
303 .map(|q| {
304 (
305 q.id.clone(),
306 RawAnswer::Abstain {
307 reason: "no decision engine attached".into(),
308 },
309 )
310 })
311 .collect(),
312 latency_ms: 0,
313 })
314 }
315}
316
317#[derive(Debug, Clone)]
319pub struct StaticEngine {
320 pub descriptor: EngineDescriptor,
321 pub answers: BTreeMap<String, RawAnswer>,
322}
323
324impl DecisionEngine for StaticEngine {
325 fn descriptor(&self) -> EngineDescriptor {
326 self.descriptor.clone()
327 }
328
329 fn decide(&self, request: &DecisionRequest) -> Result<RawDecision> {
330 Ok(RawDecision {
331 request_id: request.request_id(),
332 engine: self.descriptor.clone(),
333 answers: self.answers.clone(),
334 latency_ms: 0,
335 })
336 }
337}
338
339#[derive(Debug, Clone, Serialize, PartialEq)]
340#[serde(tag = "type", rename_all = "snake_case")]
341pub enum AdmittedAnswer {
342 Choice {
343 choice: String,
344 probabilities: BTreeMap<String, f64>,
345 confidence: Option<f64>,
346 },
347 Score {
348 score: f64,
350 normalized: f64,
352 level: usize,
354 probabilities: BTreeMap<String, f64>,
355 confidence: Option<f64>,
356 },
357 Noul {
358 p_true: f64,
359 },
360 Abstain {
361 reason: String,
362 },
363}
364
365impl AdmittedAnswer {
366 pub fn is_abstain(&self) -> bool {
367 matches!(self, Self::Abstain { .. })
368 }
369
370 pub fn p_true(&self) -> Option<f64> {
371 match self {
372 Self::Noul { p_true } => Some(*p_true),
373 _ => None,
374 }
375 }
376
377 pub fn normalized_score(&self) -> Option<f64> {
378 match self {
379 Self::Score { normalized, .. } => Some(*normalized),
380 _ => None,
381 }
382 }
383}
384
385#[derive(Debug, Clone, Serialize)]
387#[non_exhaustive]
388pub struct AdmittedDecision {
389 pub request_id: String,
390 pub engine: EngineDescriptor,
391 pub answers: BTreeMap<String, AdmittedAnswer>,
392 pub rejected: Option<String>,
394 pub latency_ms: u64,
395 pub calibrated: bool,
397}
398
399impl AdmittedDecision {
400 fn abstain_all(
401 request: &DecisionRequest,
402 engine: EngineDescriptor,
403 reason: String,
404 latency_ms: u64,
405 ) -> Self {
406 Self {
407 request_id: request.request_id(),
408 engine,
409 answers: request
410 .questions
411 .iter()
412 .map(|q| {
413 (
414 q.id.clone(),
415 AdmittedAnswer::Abstain {
416 reason: reason.clone(),
417 },
418 )
419 })
420 .collect(),
421 rejected: Some(reason),
422 latency_ms,
423 calibrated: false,
424 }
425 }
426
427 pub fn answer(&self, question_id: &str) -> Option<&AdmittedAnswer> {
428 self.answers.get(question_id)
429 }
430
431 pub fn prediction_traces(&self, request: &DecisionRequest) -> Vec<Trace> {
434 let mut traces = Vec::new();
435 for question in &request.questions {
436 let Some(answer) = self.answers.get(&question.id) else {
437 continue;
438 };
439 let (answer_kind, value, p, probabilities) = match answer {
440 AdmittedAnswer::Abstain { .. } => continue,
441 AdmittedAnswer::Noul { p_true } => (
442 "noul",
443 if *p_true >= 0.5 { "true" } else { "false" }.to_string(),
444 Some(*p_true),
445 BTreeMap::from([
446 ("true".to_string(), *p_true),
447 ("false".to_string(), 1.0 - p_true),
448 ]),
449 ),
450 AdmittedAnswer::Choice {
451 choice,
452 probabilities,
453 confidence,
454 } => (
455 "choice",
456 choice.clone(),
457 probabilities.get(choice).copied().or(*confidence),
458 probabilities.clone(),
459 ),
460 AdmittedAnswer::Score {
461 level,
462 probabilities,
463 confidence,
464 ..
465 } => (
466 "score",
467 level.to_string(),
468 probabilities
469 .get(&level.to_string())
470 .copied()
471 .or(*confidence),
472 probabilities.clone(),
473 ),
474 };
475 let answer_space: Vec<String> = match &question.kind {
476 QuestionKind::Noul { .. } => vec!["true".into(), "false".into()],
477 QuestionKind::Choice { options } => options.keys().cloned().collect(),
478 QuestionKind::Score { levels } => {
479 (0..levels.len()).map(|i| i.to_string()).collect()
480 }
481 };
482 let shown_p = p
483 .map(|p| format!("{p:.2}"))
484 .unwrap_or_else(|| "unknown".into());
485 let mut trace = Trace::new(
486 TraceKind::Prediction,
487 format!(
488 "{} → {value} (p={shown_p}, {})",
489 question.instructions.trim(),
490 self.engine.source()
491 ),
492 self.engine.source(),
493 );
494 trace.tags = vec!["prediction".into(), question.family().to_string()];
495 trace.salience = 0.3;
496 trace.confidence = 0.5;
497 trace.prediction = Some(PredictionRecord {
498 request_id: self.request_id.clone(),
499 question_id: question.id.clone(),
500 family: question.family().to_string(),
501 answer_kind: answer_kind.into(),
502 engine: self.engine.identity(),
503 p,
504 value,
505 probabilities,
506 answer_space,
507 calibrated: false,
508 });
509 traces.push(trace);
510 }
511 traces
512 }
513}
514
515#[derive(Debug, Clone, PartialEq)]
521pub struct Forecast {
522 pub family: String,
524 pub question: String,
526 pub kind: String,
528 pub p: f64,
530 pub value: Option<String>,
533 pub answer_space: Vec<String>,
535 pub source: String,
538 pub locator: Option<String>,
540}
541
542impl Forecast {
543 pub fn into_trace(self) -> Result<Trace> {
546 let invalid = |message: String| Err(KernelError::Invalid(message));
547 let source = self.source.trim().to_string();
548 let family = self.family.trim().to_string();
549 let question = self.question.trim().to_string();
550 if source.is_empty() || source.eq_ignore_ascii_case("unknown") {
551 return invalid("a forecast needs a named source, for example `human:<name>`".into());
552 }
553 if source.to_ascii_lowercase().starts_with(MODEL_SOURCE_PREFIX) {
554 return invalid(format!(
555 "`{MODEL_SOURCE_PREFIX}` sources are engine answers; record them with `hikmah ask --record` or `hikmah decide --record`"
556 ));
557 }
558 let named_principal = source.split_once(':').is_some_and(|(kind, name)| {
562 !kind.is_empty()
563 && kind
564 .chars()
565 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
566 && !name.trim().is_empty()
567 });
568 if !named_principal {
569 return invalid(format!(
570 "forecast source `{source}` must be `<kind>:<name>`, for example `human:alex` or `agent:planner`"
571 ));
572 }
573 if family.is_empty() || question.is_empty() {
574 return invalid("a forecast needs a family and a question".into());
575 }
576 if !unit(self.p) {
577 return invalid(format!("--p {} is not a probability in [0, 1]", self.p));
578 }
579 let kind = self.kind.trim().to_ascii_lowercase();
580 let (value, answer_space, probabilities) = match kind.as_str() {
581 "noul" => {
582 if self.value.is_some() || !self.answer_space.is_empty() {
583 return invalid(
584 "a noul forecast is P(true) in --p; it takes no --value or --answer-space"
585 .into(),
586 );
587 }
588 (
589 if self.p >= 0.5 { "true" } else { "false" }.to_string(),
590 vec!["true".to_string(), "false".to_string()],
591 BTreeMap::from([
592 ("true".to_string(), self.p),
593 ("false".to_string(), 1.0 - self.p),
594 ]),
595 )
596 }
597 "choice" | "score" => {
598 let space: Vec<String> = self
599 .answer_space
600 .iter()
601 .map(|v| v.trim().to_string())
602 .collect();
603 let bounds = if kind == "choice" { 2..=255 } else { 2..=10 };
604 let distinct: BTreeSet<&str> = space.iter().map(String::as_str).collect();
605 if !bounds.contains(&space.len())
606 || distinct.len() != space.len()
607 || distinct.contains("")
608 {
609 return invalid(format!(
610 "a {kind} forecast needs --answer-space with {}..={} distinct, non-empty values",
611 bounds.start(),
612 bounds.end()
613 ));
614 }
615 let Some(value) = self.value.as_deref().map(str::trim) else {
616 return invalid(format!("a {kind} forecast needs --value"));
617 };
618 if !distinct.contains(value) {
619 return invalid(format!("--value `{value}` is not one of {space:?}"));
620 }
621 (value.to_string(), space, BTreeMap::new())
624 }
625 other => {
626 return invalid(format!(
627 "unknown forecast type `{other}` (expected noul, choice, or score)"
628 ))
629 }
630 };
631 let created_at_ms = crate::trace::now_ms();
632 let seed = format!("{source}\n{family}\n{question}\n{created_at_ms}");
633 let request_id = format!("fc_{}", &blake3::hash(seed.as_bytes()).to_hex()[..16]);
634 let mut trace = Trace::new(
635 TraceKind::Prediction,
636 format!("{question} → {value} (p={:.2}, {source})", self.p),
637 source.clone(),
638 );
639 trace.created_at_ms = created_at_ms;
640 trace.tags = vec!["prediction".into(), family.clone()];
641 trace.salience = 0.3;
642 trace.confidence = 0.5;
643 trace.provenance.locator = self.locator;
644 trace.prediction = Some(PredictionRecord {
645 request_id,
646 question_id: family.clone(),
647 family,
648 answer_kind: kind,
649 engine: source,
650 p: Some(self.p),
651 value,
652 probabilities,
653 answer_space,
654 calibrated: false,
655 });
656 trace.validate()?;
657 Ok(trace)
658 }
659}
660
661pub fn ask(engine: &dyn DecisionEngine, request: &DecisionRequest) -> Result<AdmittedDecision> {
664 request.validate()?;
665 if request.outbound_texts().into_iter().any(contains_secret) {
666 return Err(KernelError::Invalid(
667 "the request appears to contain a credential; refusing to send it to a decision engine"
668 .into(),
669 ));
670 }
671 let started = Instant::now();
672 match engine.decide(request) {
673 Ok(raw) => Ok(admit(request, raw)),
674 Err(error) => Ok(AdmittedDecision::abstain_all(
675 request,
676 engine.descriptor(),
677 format!("engine error: {error}"),
678 started.elapsed().as_millis() as u64,
679 )),
680 }
681}
682
683pub fn admit(request: &DecisionRequest, raw: RawDecision) -> AdmittedDecision {
685 match admit_inner(request, &raw) {
686 Ok(answers) => AdmittedDecision {
687 request_id: raw.request_id,
688 engine: raw.engine,
689 answers,
690 rejected: None,
691 latency_ms: raw.latency_ms,
692 calibrated: false,
693 },
694 Err(detail) => AdmittedDecision::abstain_all(
695 request,
696 raw.engine,
697 format!("response rejected: {detail}"),
698 raw.latency_ms,
699 ),
700 }
701}
702
703fn admit_inner(
704 request: &DecisionRequest,
705 raw: &RawDecision,
706) -> std::result::Result<BTreeMap<String, AdmittedAnswer>, String> {
707 if raw.request_id != request.request_id() {
708 return Err(format!(
709 "request id {} does not match {}",
710 raw.request_id,
711 request.request_id()
712 ));
713 }
714 for id in raw.answers.keys() {
715 if request.question(id).is_none() {
716 return Err(format!("answered a question that was not asked: {id}"));
717 }
718 }
719 let mut admitted = BTreeMap::new();
720 for question in &request.questions {
721 let answer = match raw.answers.get(&question.id) {
722 None => AdmittedAnswer::Abstain {
723 reason: "engine did not answer".into(),
724 },
725 Some(answer) => admit_answer(question, answer)?,
726 };
727 admitted.insert(question.id.clone(), answer);
728 }
729 Ok(admitted)
730}
731
732fn unit(value: f64) -> bool {
733 value.is_finite() && (0.0..=1.0).contains(&value)
734}
735
736fn check_confidence(
737 question: &Question,
738 confidence: Option<f64>,
739) -> std::result::Result<(), String> {
740 match confidence {
741 Some(c) if !unit(c) => Err(format!("{}: confidence {c} is not in [0,1]", question.id)),
742 _ => Ok(()),
743 }
744}
745
746fn check_distribution(
747 question: &Question,
748 probabilities: &BTreeMap<String, f64>,
749 allowed: impl Fn(&str) -> bool,
750) -> std::result::Result<(), String> {
751 if probabilities.is_empty() {
752 return Ok(());
753 }
754 let mut sum = 0.0;
755 for (key, p) in probabilities {
756 if !allowed(key) {
757 return Err(format!(
758 "{}: probability for unknown option `{key}`",
759 question.id
760 ));
761 }
762 if !unit(*p) {
763 return Err(format!(
764 "{}: probability {p} for `{key}` is not in [0,1]",
765 question.id
766 ));
767 }
768 sum += p;
769 }
770 if (sum - 1.0).abs() > sum_tolerance(probabilities.len()) {
771 return Err(format!(
772 "{}: probabilities sum to {sum:.3}, not 1",
773 question.id
774 ));
775 }
776 Ok(())
777}
778
779fn admit_answer(
780 question: &Question,
781 answer: &RawAnswer,
782) -> std::result::Result<AdmittedAnswer, String> {
783 match (answer, &question.kind) {
784 (RawAnswer::Malformed { detail }, _) => {
785 Err(format!("{}: malformed answer: {detail}", question.id))
786 }
787 (RawAnswer::Abstain { reason }, _) => Ok(AdmittedAnswer::Abstain {
788 reason: reason.clone(),
789 }),
790 (RawAnswer::Noul { noul }, QuestionKind::Noul { .. }) => {
791 if unit(*noul) {
792 Ok(AdmittedAnswer::Noul { p_true: *noul })
793 } else {
794 Err(format!("{}: noul {noul} is not in [0,1]", question.id))
795 }
796 }
797 (
798 RawAnswer::Choice {
799 choice,
800 probabilities,
801 confidence,
802 },
803 QuestionKind::Choice { options },
804 ) => {
805 if !options.contains_key(choice) {
806 return Err(format!(
807 "{}: `{choice}` is not one of the offered options",
808 question.id
809 ));
810 }
811 check_confidence(question, *confidence)?;
812 let probabilities = probabilities.clone().unwrap_or_default();
813 check_distribution(question, &probabilities, |k| options.contains_key(k))?;
814 if !probabilities.is_empty() {
815 let chosen = probabilities.get(choice).copied().unwrap_or(0.0);
816 let best = probabilities.values().copied().fold(0.0_f64, f64::max);
817 if chosen <= 0.0 || chosen + 1e-9 < best {
818 return Err(format!(
819 "{}: `{choice}` is not the most probable option in its own distribution",
820 question.id
821 ));
822 }
823 }
824 Ok(AdmittedAnswer::Choice {
825 choice: choice.clone(),
826 probabilities,
827 confidence: *confidence,
828 })
829 }
830 (
831 RawAnswer::Score {
832 score,
833 probabilities,
834 confidence,
835 },
836 QuestionKind::Score { levels },
837 ) => {
838 let top = (levels.len() - 1) as f64;
839 if !score.is_finite() || *score < 0.0 || *score > top {
840 return Err(format!(
841 "{}: score {score} is outside [0, {top}]",
842 question.id
843 ));
844 }
845 check_confidence(question, *confidence)?;
846 let probabilities = probabilities.clone().unwrap_or_default();
847 check_distribution(question, &probabilities, |k| {
849 k.parse::<usize>()
850 .map(|i| i < levels.len() && k == i.to_string())
851 .unwrap_or(false)
852 })?;
853 if !probabilities.is_empty() {
854 let expected: f64 = probabilities
855 .iter()
856 .map(|(k, p)| k.parse::<f64>().unwrap_or(0.0) * p)
857 .sum();
858 let tolerance = 0.02 * top + 0.02;
859 if (expected - score).abs() > tolerance {
860 return Err(format!(
861 "{}: score {score} disagrees with its distribution (expected {expected:.3})",
862 question.id
863 ));
864 }
865 }
866 Ok(AdmittedAnswer::Score {
867 score: *score,
868 normalized: score / top,
869 level: score.round() as usize,
870 probabilities,
871 confidence: *confidence,
872 })
873 }
874 (answer, kind) => Err(format!(
875 "{}: answer type does not match a {} question: {answer:?}",
876 question.id,
877 kind.name()
878 )),
879 }
880}