Proposals, records and rules¶
The code keeps three families of types apart, and the boundary between them is where a model's output stops and the application's decisions start.
| Family | Lives in | Written by | Example |
|---|---|---|---|
| Proposal | domain/*, named …Proposal, …Set or the agent's reply type |
a model, decoded from JSON | SearchPlanProposal, ExtractionSet, AssessmentProposal, ApproachSet |
| Record | an entity's state and events | the entity, from a command | SearchPlan, EvidenceRecord, EvidenceAssessment, DesignState |
| Rule result | rules/* |
a pure function | Defect, PackageCheck, DesignCheck, Eligibility, RuleOutcomeRecord |
A proposal is loose on purpose¶
A proposal's fields are mostly strings, because it is what a model wrote. The search agent's reply is an example:
/** The search agent's reply: queries, targets, filters, a stopping criterion, and expansions. */
final case class SearchPlanProposal(
queries: Vector[QueryProposal],
targetSources: Vector[String],
filters: Map[String, String] = Map.empty,
stoppingCriterion: String,
escalation: String = "supplemental_search",
expansions: Vector[String] = Vector.empty
)
The agent handler that produces it builds the prompt and decodes the reply; it does nothing else:
def plan(request: PlanRequest): Effect[SearchPlanProposal] =
val prompt = SearchAgent.planPrompt
effects
.systemMessage(prompt.system)
.userMessage(
Prompts.userMessage(
SearchAgent.planMarker(request.question),
"Write the search plan for this scientific question."
)
)
.withContext(
s"Scientific question:\n${request.question}\n\nEvidence needed:\n${request.evidenceNeeded
.map("- " + _)
.mkString("\n")}\n\nAnswer fields:\n${request.answerFields.mkString(", ")}\n\nAvailable sources:\n${request.availableSources
.mkString(", ")}"
)
.memory(MemoryProvider.none)
.thenReplyAs[SearchPlanProposal]
A gate turns a proposal into typed input, or a defect¶
The gate is a pure function. It parses the strings into closed enumerations and checks what the stage
requires. Its result is either the typed values the record needs or a classified Defect, which says
whether a retry might help:
/** The plan gate: known kinds, both kinds present, a known escalation. */
def gate(p: SearchPlanProposal): Either[Defect, (Vector[Query], Escalation)] =
val queries = p.queries.flatMap(q =>
QueryKind.values
.find(_.toString == q.kind.trim.toLowerCase)
.map(k => Query(k, q.text.trim, q.expansionOf))
)
if queries.size != p.queries.size then
Left(
Rules.defect(
DefectKind.schema_invalid,
"a query kind is not lexical or semantic",
retryable = true
)
)
else if queries.exists(_.text.isEmpty) then
Left(Rules.defect(DefectKind.schema_invalid, "a query has no text", retryable = true))
else if !SearchPlan.twoKinds(queries) then
Left(
Rules.defect(
DefectKind.schema_invalid,
"a plan has at least one lexical and one semantic query",
retryable = true
)
)
else if p.targetSources.forall(_.isBlank) then
Left(
Rules.defect(DefectKind.schema_invalid, "a plan names its target sources", retryable = true)
)
else if p.stoppingCriterion.isBlank then
Left(
Rules.defect(
DefectKind.schema_invalid,
"a plan states its stopping criterion",
retryable = true
)
)
else
Right(
(
queries,
Escalation.values
.find(_.toString == p.escalation.trim.replace(' ', '_'))
.getOrElse(Escalation.supplemental_search)
)
)
A record enforces its own invariants¶
The entity checks again, because a gate is only one caller. The search plan entity refuses a plan without both kinds of query whoever sends it, and a refusal persists nothing:
def plan(request: RecordPlan): Effect[SearchPlan] =
if request.queries.isEmpty then effects.error("a plan has at least one query")
else if !SearchPlan.twoKinds(request.queries) then
effects.error("a plan has at least one lexical and one semantic query")
else if request.targetSources.isEmpty then effects.error("a plan names its target sources")
else if request.stoppingCriterion.isBlank then effects.error("a plan states when to stop")
else
effects
.persist(
SearchPlanEvent.Planned(
request.questionRef,
request.queries,
request.targetSources,
request.filters,
request.parameters,
request.stoppingCriterion,
request.escalation,
request.expansions,
request.plannedBy,
Instant.now()
)
)
.thenReplyState
Records refer to each other by Ref(kind, id, version), never by embedding. A record keeps the
references it was made from in dependsOn, which the dependency view reads to answer "what was built on
this?".
A rule rewrites a proposal and says so¶
Some rules correct rather than refuse. The applicability rule set rewrites a model's "direct" reading of evidence from another species to "indirect", and records the rewrite on the assessment, so a reader can see what the model proposed and what the rule changed:
if species == DimensionMatch.mismatch && applicability == Applicability.direct then
applicability = Applicability.indirect
results += RuleOutcomeRecord(
speciesRule.key,
"adjusted",
"direct rewritten to indirect: the species does not match"
)
else results += RuleOutcomeRecord(speciesRule.key, "passed", s"species ${species}")
Every stage reports one of three outcomes¶
The helper every model-backed stage uses returns one of three outcomes, so a workflow step handles a skipped, completed or failed call explicitly:
/** What a stage's model call came to. The caller records the outcome's records, then completes. */
enum StageOutcome[+O]:
case Skipped(outputRef: Option[Ref])
case Done(attempt: Int, value: O, modelCall: ModelCall)
case Failed(attempt: Int, defect: Defect, modelCall: Option[ModelCall])
Closed lists¶
Every value with a fixed set of meanings is a Scala 3 enum whose case names are the wire strings:
relationship (supports, contradicts, qualifies, background, not_applicable), applicability
(direct, indirect, unknown), record status, sufficiency status, defect kind and the
rest. A model reply carries strings; the gate maps them to an enum or refuses the reply as
schema_invalid. Inside the application, nothing is matched against free text.
Identifiers¶
Identifiers are derived from what a record is, so a retried step addresses the same record instead of
creating a second one: a facet is <request>:f<n>, a question <request>:q<n>, a finding
<source>:v<version>:fd<n>, an assessment <question>:<record>, a package <question>:pk<n>, an
approach <request>:c<n>, a design <request>:design. A workflow's id is derived the same way, which is
what makes a redelivered event start the run already started rather than a new one.