///|
/// Provider-neutral semantic judgement primitives. A DecisionPort evaluates
/// structured state against independent typed questions and returns
/// probabilities; policy, authority, and execution stay with the consuming
/// extension.
///|
/// One named alternative for a `Choice` question.
pub(all) struct DecisionOption {
id : String
description : Json?
} derive(Eq, Debug)
///|
pub extend DecisionOption with Eq::{not_equal, equal}
///|
pub extend DecisionOption with Debug::{to_repr}
///|
/// Typed question understood by a `DecisionPort`.
pub(all) enum DecisionQuestion {
/// Probability that the proposition is true. Criteria are optional outcome
/// descriptions, not policy thresholds.
Boolean(
id~ : String,
instructions~ : Json,
true_criteria~ : Json?,
false_criteria~ : Json?
)
/// Select one named alternative and return the complete probability
/// distribution over the supplied options.
Choice(id~ : String, instructions~ : Json, options~ : Array[DecisionOption])
/// Score against an ordered rubric. Level index is the score coordinate;
/// at least two levels are required by conforming adapters.
Score(id~ : String, instructions~ : Json, levels~ : Array[Json])
} derive(Eq, Debug)
///|
pub extend DecisionQuestion with Eq::{not_equal, equal}
///|
pub extend DecisionQuestion with Debug::{to_repr}
///|
/// Structured state plus a non-empty set of independent semantic questions.
pub(all) struct DecisionRequest {
state : Json
questions : Array[DecisionQuestion]
} derive(Eq, Debug)
///|
pub extend DecisionRequest with Eq::{not_equal, equal}
///|
pub extend DecisionRequest with Debug::{to_repr}
///|
/// One named probability in a choice distribution.
pub(all) struct DecisionNamedProbability {
id : String
probability : Double
} derive(Eq, Debug)
///|
pub extend DecisionNamedProbability with Eq::{not_equal, equal}
///|
pub extend DecisionNamedProbability with Debug::{to_repr}
///|
/// Provider-neutral typed answer. The question id is repeated so adapters can
/// be validated without relying on response ordering.
pub(all) enum DecisionAnswer {
BooleanAnswer(id~ : String, probability_true~ : Double)
ChoiceAnswer(
id~ : String,
selected~ : String,
probabilities~ : Array[DecisionNamedProbability],
confidence~ : Double?
)
ScoreAnswer(
id~ : String,
score~ : Double,
probabilities~ : Array[Double],
confidence~ : Double?
)
} derive(Eq, Debug)
///|
pub extend DecisionAnswer with Eq::{not_equal, equal}
///|
pub extend DecisionAnswer with Debug::{to_repr}
///|
/// Optional token accounting supplied by decision providers.
pub(all) struct DecisionUsage {
input_tokens : Int?
output_tokens : Int?
} derive(Eq, Debug)
///|
pub extend DecisionUsage with Eq::{not_equal, equal}
///|
pub extend DecisionUsage with Debug::{to_repr}
///|
/// Typed answers plus provider-reported model/usage metadata. `model` is
/// descriptive observability only; consumers must not branch policy on a
/// provider identity hidden behind this port.
pub(all) struct DecisionResult {
answers : Array[DecisionAnswer]
model : String?
usage : DecisionUsage?
} derive(Eq, Debug)
///|
pub extend DecisionResult with Eq::{not_equal, equal}
///|
pub extend DecisionResult with Debug::{to_repr}
///|
/// A low-cost semantic judgement capability. The port does not decide final
/// business policy: consumers map the returned probabilities into their own
/// deterministic policy and authority flow.
pub(open) trait DecisionPort {
async fn evaluate(Self, request : DecisionRequest) -> DecisionResult raise @error.DecisionError
}
///|
fn DecisionQuestion::id(self : DecisionQuestion) -> String {
match self {
Boolean(id~, ..) | Choice(id~, ..) | Score(id~, ..) => id
}
}
///|
fn DecisionAnswer::id(self : DecisionAnswer) -> String {
match self {
BooleanAnswer(id~, ..) | ChoiceAnswer(id~, ..) | ScoreAnswer(id~, ..) => id
}
}
///|
fn valid_probability(value : Double) -> Bool {
!value.is_nan() && !value.is_inf() && value >= 0.0 && value <= 1.0
}
///|
fn validate_confidence(
question_id : String,
confidence : Double?,
) -> Unit raise @error.DecisionError {
match confidence {
Some(value) if !valid_probability(value) =>
raise @error.DecisionError::ResponseParse(
"confidence for question '" +
question_id +
"' must be finite and in [0,1]",
)
_ => ()
}
}
///|
/// Validate provider-neutral request invariants before an adapter builds its
/// wire request. Providers may impose additional limits, but must not accept
/// requests that violate these base invariants.
pub fn DecisionRequest::validate(
self : DecisionRequest,
) -> Unit raise @error.DecisionError {
if self.questions.is_empty() {
raise @error.DecisionError::InvalidRequest(
"at least one decision question is required",
)
}
let ids : Map[String, Bool] = Map::from_array([])
for question in self.questions {
let id = question.id()
if id == "" {
raise @error.DecisionError::InvalidRequest(
"decision question id must not be empty",
)
}
if ids.contains(id) {
raise @error.DecisionError::InvalidRequest(
"duplicate decision question id '" + id + "'",
)
}
ids[id] = true
match question {
Boolean(..) => ()
Choice(id~, options~, ..) => {
if options.length() < 2 {
raise @error.DecisionError::InvalidRequest(
"choice question '" + id + "' requires at least two options",
)
}
let option_ids : Map[String, Bool] = Map::from_array([])
for option in options {
if option.id == "" {
raise @error.DecisionError::InvalidRequest(
"choice question '" + id + "' has an empty option id",
)
}
if option_ids.contains(option.id) {
raise @error.DecisionError::InvalidRequest(
"choice question '" +
id +
"' has duplicate option '" +
option.id +
"'",
)
}
option_ids[option.id] = true
}
}
Score(id~, levels~, ..) =>
if levels.length() < 2 {
raise @error.DecisionError::InvalidRequest(
"score question '" + id + "' requires at least two levels",
)
}
}
}
}
///|
/// Validate a provider result against the request that produced it. Adapters
/// should call this before returning so malformed or semantically mismatched
/// provider payloads fail loudly as `DecisionError::ResponseParse`.
pub fn DecisionResult::validate_for(
self : DecisionResult,
request : DecisionRequest,
) -> Unit raise @error.DecisionError {
request.validate()
if self.answers.length() != request.questions.length() {
raise @error.DecisionError::ResponseParse(
"decision answer count does not match question count",
)
}
let questions : Map[String, DecisionQuestion] = Map::from_array([])
for question in request.questions {
questions[question.id()] = question
}
let seen : Map[String, Bool] = Map::from_array([])
for answer in self.answers {
let id = answer.id()
if seen.contains(id) {
raise @error.DecisionError::ResponseParse(
"duplicate decision answer id '" + id + "'",
)
}
seen[id] = true
if !questions.contains(id) {
raise @error.DecisionError::ResponseParse(
"decision answer id '" + id + "' was not requested",
)
}
match (questions[id], answer) {
(Boolean(..), BooleanAnswer(probability_true~, ..)) =>
if !valid_probability(probability_true) {
raise @error.DecisionError::ResponseParse(
"boolean probability for question '" +
id +
"' must be finite and in [0,1]",
)
}
(
Choice(options~, ..),
ChoiceAnswer(selected~, probabilities~, confidence~, ..),
) => {
validate_confidence(id, confidence)
if probabilities.length() != options.length() {
raise @error.DecisionError::ResponseParse(
"choice probabilities for question '" +
id +
"' do not match option count",
)
}
let allowed : Map[String, Bool] = Map::from_array([])
for option in options {
allowed[option.id] = true
}
if !allowed.contains(selected) {
raise @error.DecisionError::ResponseParse(
"choice answer for question '" +
id +
"' selected unknown option '" +
selected +
"'",
)
}
let probability_ids : Map[String, Bool] = Map::from_array([])
let mut sum = 0.0
for entry in probabilities {
if !allowed.contains(entry.id) || probability_ids.contains(entry.id) {
raise @error.DecisionError::ResponseParse(
"choice probabilities for question '" +
id +
"' contain an unknown or duplicate option",
)
}
if !valid_probability(entry.probability) {
raise @error.DecisionError::ResponseParse(
"choice probability for question '" +
id +
"' must be finite and in [0,1]",
)
}
probability_ids[entry.id] = true
sum = sum + entry.probability
}
if (sum - 1.0).abs() > 0.000001 {
raise @error.DecisionError::ResponseParse(
"choice probabilities for question '" + id + "' must sum to 1",
)
}
}
(Score(levels~, ..), ScoreAnswer(score~, probabilities~, confidence~, ..)) => {
validate_confidence(id, confidence)
if probabilities.length() != levels.length() {
raise @error.DecisionError::ResponseParse(
"score probabilities for question '" +
id +
"' do not match level count",
)
}
if score.is_nan() ||
score.is_inf() ||
score < 0.0 ||
score > (levels.length() - 1).to_double() {
raise @error.DecisionError::ResponseParse(
"score for question '" + id + "' is outside the rubric range",
)
}
let mut sum = 0.0
for probability in probabilities {
if !valid_probability(probability) {
raise @error.DecisionError::ResponseParse(
"score probability for question '" +
id +
"' must be finite and in [0,1]",
)
}
sum = sum + probability
}
if (sum - 1.0).abs() > 0.000001 {
raise @error.DecisionError::ResponseParse(
"score probabilities for question '" + id + "' must sum to 1",
)
}
}
_ =>
raise @error.DecisionError::ResponseParse(
"decision answer kind does not match question '" + id + "'",
)
}
}
}