use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::BTreeSet;
pub(crate) const PROMPT_COMPREHENSION_MODEL_CARD_PREFIX: &str = "[NEWT PROMPT COMPREHENSION v1]";
const MAX_ATOMIC_ASKS: usize = 64;
const MAX_DECISIONS: usize = 16;
const MAX_CONCRETE_DECISIONS: usize = MAX_DECISIONS - 1;
const MAX_ASK_BYTES: usize = 4_096;
const MAX_CLARIFICATION_BYTES: usize = 384;
const RESEARCH_TOOL_ROUND_LIMIT: usize = 3;
pub(super) const PROMPT_COMPREHENSION_SCHEMA_V1: &str = "prompt_comprehension_manifest_v1";
pub(super) const PROMPT_COMPREHENSION_SCHEMA_V2: &str = "prompt_comprehension_manifest_v2";
pub(super) const PROMPT_COMPREHENSION_SCHEMA_CURRENT: &str = PROMPT_COMPREHENSION_SCHEMA_V2;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PromptDisposition {
Ask,
Act,
Explain,
Research,
Plan,
}
impl PromptDisposition {
pub fn as_str(self) -> &'static str {
match self {
Self::Ask => "ask",
Self::Act => "act",
Self::Explain => "explain",
Self::Research => "research",
Self::Plan => "plan",
}
}
pub fn tool_round_limit(self, max: usize) -> usize {
match self {
Self::Ask => 0,
Self::Act => max,
Self::Explain => max,
Self::Research => max.min(RESEARCH_TOOL_ROUND_LIMIT),
Self::Plan => max,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AtomicAsk {
text: String,
}
impl AtomicAsk {
pub fn text(&self) -> &str {
&self.text
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecisionStatus {
Pending,
Locked,
}
impl DecisionStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Locked => "locked",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecisionSource {
Operator,
Policy,
AuthorizedAssumption,
}
impl DecisionSource {
pub fn as_str(self) -> &'static str {
match self {
Self::Operator => "operator",
Self::Policy => "policy",
Self::AuthorizedAssumption => "authorized_assumption",
}
}
}
#[derive(Debug, Clone)]
pub struct DecisionLock {
question: String,
status: DecisionStatus,
source: Option<DecisionSource>,
overflow: bool,
}
impl DecisionLock {
pub fn question(&self) -> &str {
&self.question
}
pub fn status(&self) -> DecisionStatus {
self.status
}
pub fn source(&self) -> Option<DecisionSource> {
self.source
}
pub fn is_overflow(&self) -> bool {
self.overflow
}
}
#[derive(Debug, Clone)]
pub struct PromptComprehensionManifest {
atomic_asks: Vec<AtomicAsk>,
decisions: Vec<DecisionLock>,
}
impl PromptComprehensionManifest {
pub fn atomic_asks(&self) -> &[AtomicAsk] {
&self.atomic_asks
}
pub fn decision_count(&self) -> usize {
self.decisions.len()
}
pub fn decisions(&self) -> &[DecisionLock] {
&self.decisions
}
pub fn pending_decision_count(&self) -> usize {
self.decisions
.iter()
.filter(|decision| decision.status == DecisionStatus::Pending)
.count()
}
pub fn locked_decision_count(&self) -> usize {
self.decisions
.iter()
.filter(|decision| decision.status == DecisionStatus::Locked)
.count()
}
}
#[derive(Debug, Clone)]
pub struct PromptIntake {
manifest: PromptComprehensionManifest,
disposition: PromptDisposition,
post_lock_disposition: PromptDisposition,
}
impl PromptIntake {
pub fn analyze_with(prompt: &str, lexicon: &DispositionLexicon) -> Self {
let mut intake = Self::analyze(prompt);
if prompt.trim().is_empty() {
return intake; }
intake.post_lock_disposition = infer_disposition_with(prompt, lexicon);
if intake.disposition != PromptDisposition::Ask {
intake.disposition = intake.post_lock_disposition;
}
debug_assert!(intake.validate().is_ok());
intake
}
pub fn analyze(prompt: &str) -> Self {
if prompt.trim().is_empty() {
let intake = Self {
manifest: PromptComprehensionManifest {
atomic_asks: vec![AtomicAsk {
text: "(empty operator prompt)".to_string(),
}],
decisions: vec![DecisionLock {
question: "Provide a non-empty task before execution.".to_string(),
status: DecisionStatus::Pending,
source: None,
overflow: false,
}],
},
disposition: PromptDisposition::Ask,
post_lock_disposition: PromptDisposition::Explain,
};
debug_assert!(intake.validate().is_ok());
return intake;
}
let (atomic_asks, atomic_overflow) = extract_atomic_asks(prompt);
let post_lock_disposition = infer_disposition(prompt);
let (mut decisions, decision_overflow) = extract_decisions(&atomic_asks);
if atomic_overflow || decision_overflow {
decisions.push(overflow_decision());
}
let disposition = if decisions
.iter()
.any(|decision| decision.status == DecisionStatus::Pending)
{
PromptDisposition::Ask
} else {
post_lock_disposition
};
let intake = Self {
manifest: PromptComprehensionManifest {
atomic_asks,
decisions,
},
disposition,
post_lock_disposition,
};
debug_assert!(intake.validate().is_ok());
intake
}
pub fn disposition(&self) -> PromptDisposition {
self.disposition
}
pub fn enforce_read_only(&mut self, disposition: PromptDisposition) {
if !matches!(
disposition,
PromptDisposition::Explain | PromptDisposition::Research | PromptDisposition::Plan
) {
debug_assert!(false, "read-only disposition required");
return;
}
if self.disposition != PromptDisposition::Ask {
self.disposition = disposition;
self.post_lock_disposition = disposition;
}
debug_assert!(self.validate().is_ok());
}
pub fn atomic_asks(&self) -> &[AtomicAsk] {
self.manifest.atomic_asks()
}
pub fn manifest(&self) -> &PromptComprehensionManifest {
&self.manifest
}
pub fn clarification_batch(&self) -> String {
let pending = self
.manifest
.decisions
.iter()
.enumerate()
.filter(|(_, decision)| decision.status == DecisionStatus::Pending)
.collect::<Vec<_>>();
if pending.is_empty() {
return String::new();
}
let mut rendered = String::from(
"I need these decisions locked before I can execute. Reply using an explicit ordinal for every item, for example `1: …`:\n",
);
for (ordinal, decision) in pending {
let question = truncate_chars(&decision.question, MAX_CLARIFICATION_BYTES);
rendered.push_str(&format!("{}. {}\n", ordinal + 1, question));
}
rendered.trim_end().to_string()
}
pub fn resolve_with_operator_answer(&self, answer: &str) -> Self {
if self
.manifest
.atomic_asks
.first()
.is_some_and(|ask| ask.text == "(empty operator prompt)")
{
return Self::analyze(answer);
}
if self
.manifest
.decisions
.iter()
.any(DecisionLock::is_overflow)
{
return self.clone();
}
let mut resolved = self.clone();
let pending = resolved
.manifest
.decisions
.iter()
.enumerate()
.filter_map(|(index, decision)| {
(decision.status == DecisionStatus::Pending).then_some(index)
})
.collect::<Vec<_>>();
if let Some(indices) = explicit_answer_indices(answer, &pending) {
for index in indices {
let decision = &mut resolved.manifest.decisions[index];
decision.status = DecisionStatus::Locked;
decision.source = Some(DecisionSource::Operator);
}
}
resolved.disposition = if resolved.manifest.pending_decision_count() == 0 {
resolved.post_lock_disposition
} else {
PromptDisposition::Ask
};
debug_assert!(resolved.validate().is_ok());
resolved
}
pub fn model_card(&self) -> String {
let pending = self.manifest.pending_decision_count();
let locked = self.manifest.locked_decision_count();
let instruction = match self.disposition {
PromptDisposition::Ask => {
"harness_action: await the bounded operator clarification; do not call tools"
}
PromptDisposition::Act => {
"harness_action: decisions are locked; ordinary execution authority is available"
}
PromptDisposition::Explain => {
"harness_action: answer without mutation; bounded read/recovery tools only"
}
PromptDisposition::Research => {
"harness_action: gather bounded read-only evidence; do not mutate or request capability grants"
}
PromptDisposition::Plan => {
"harness_action: read evidence and maintain the harness plan ledger only; do not mutate the workspace, execute commands, or request capability grants"
}
};
let prompt = self
.manifest
.atomic_asks
.iter()
.map(AtomicAsk::text)
.collect::<Vec<_>>()
.join("\n");
let refinement = request_refinement_model_card(&prompt);
let mut card = format!(
"{PROMPT_COMPREHENSION_MODEL_CARD_PREFIX}\n\
disposition: {}\n\
atomic_ask_count: {}\n\
decision_count: {}\n\
pending_decision_count: {pending}\n\
locked_decision_count: {locked}\n\
{instruction}",
self.disposition.as_str(),
self.manifest.atomic_asks.len(),
self.manifest.decisions.len(),
);
if !refinement.is_empty() {
card.push('\n');
card.push_str(&refinement);
}
card
}
pub fn artifact_metadata(&self) -> Value {
let mut status_counts = serde_json::Map::new();
status_counts.insert(
DecisionStatus::Pending.as_str().to_string(),
Value::from(self.manifest.pending_decision_count() as u64),
);
status_counts.insert(
DecisionStatus::Locked.as_str().to_string(),
Value::from(self.manifest.locked_decision_count() as u64),
);
let mut source_counts = serde_json::Map::new();
for source in [
DecisionSource::Operator,
DecisionSource::Policy,
DecisionSource::AuthorizedAssumption,
] {
let count = self
.manifest
.decisions
.iter()
.filter(|decision| decision.source == Some(source))
.count();
source_counts.insert(source.as_str().to_string(), Value::from(count as u64));
}
json!({
"schema": PROMPT_COMPREHENSION_SCHEMA_CURRENT,
"disposition": self.disposition.as_str(),
"atomic_ask_count": self.manifest.atomic_asks.len() as u64,
"clarification_count": self.manifest.pending_decision_count() as u64,
"decision_count": self.manifest.decisions.len() as u64,
"decision_status_counts": Value::Object(status_counts),
"decision_source_counts": Value::Object(source_counts),
"atomic_ask_digests": self
.manifest
.atomic_asks
.iter()
.map(|ask| digest_metadata(&ask.text))
.collect::<Vec<_>>(),
"clarification_digests": self
.manifest
.decisions
.iter()
.filter(|decision| decision.status == DecisionStatus::Pending)
.map(|decision| digest_metadata(&decision.question))
.collect::<Vec<_>>(),
})
}
pub fn validate(&self) -> Result<(), String> {
if self.manifest.atomic_asks.len() > MAX_ATOMIC_ASKS {
return Err("atomic ask count exceeds the intake bound".to_string());
}
if self.manifest.decisions.len() > MAX_DECISIONS {
return Err("decision count exceeds the intake bound".to_string());
}
if self.post_lock_disposition == PromptDisposition::Ask {
return Err("post-lock disposition cannot be ask".to_string());
}
for ask in &self.manifest.atomic_asks {
if ask.text.is_empty() || ask.text.len() > MAX_ASK_BYTES {
return Err("atomic ask is empty or exceeds the intake bound".to_string());
}
}
for decision in &self.manifest.decisions {
if decision.question.is_empty() || decision.question.len() > MAX_ASK_BYTES {
return Err("decision text is empty or exceeds the intake bound".to_string());
}
match (decision.status, decision.source) {
(DecisionStatus::Pending, None) | (DecisionStatus::Locked, Some(_)) => {}
(DecisionStatus::Pending, Some(_)) => {
return Err("pending decision has a lock source".to_string())
}
(DecisionStatus::Locked, None) => {
return Err("locked decision lacks a source".to_string())
}
}
}
let pending = self.manifest.pending_decision_count();
if (pending > 0) != (self.disposition == PromptDisposition::Ask) {
return Err("disposition does not match pending decisions".to_string());
}
if pending == 0 && self.disposition != self.post_lock_disposition {
return Err("resolved disposition does not match task disposition".to_string());
}
Ok(())
}
}
fn extract_atomic_asks(prompt: &str) -> (Vec<AtomicAsk>, bool) {
let mut asks = Vec::new();
for line in prompt.lines() {
let line = strip_list_marker(line.trim());
if line.is_empty() {
continue;
}
for semicolon_clause in line.split(';') {
for clause in semicolon_clause.split(". ") {
let clause = clause.trim();
if clause.is_empty() {
continue;
}
if asks.len() == MAX_ATOMIC_ASKS {
return (asks, true);
}
asks.push(AtomicAsk {
text: truncate_chars(clause, MAX_ASK_BYTES),
});
}
}
}
if asks.is_empty() {
asks.push(AtomicAsk {
text: "(empty operator prompt)".to_string(),
});
}
(asks, false)
}
fn strip_list_marker(line: &str) -> &str {
let line = line.trim_start_matches(['-', '*', '•', ' ']);
let digits = line.bytes().take_while(u8::is_ascii_digit).count();
if digits > 0 {
let rest = &line[digits..];
if let Some(rest) = rest.strip_prefix(['.', ')']) {
return rest.trim_start();
}
}
line
}
fn request_refinement_model_card(prompt: &str) -> String {
let lower = prompt.to_ascii_lowercase();
let packs = crate::api_surface::builtin_packs();
let language = crate::api_surface::detect_source_language(prompt, &packs);
let contains = |needle| crate::api_surface::contains_bounded_ascii(&lower, needle);
let names_source_files = ["code file", "code files", "source file", "source files"]
.iter()
.any(|needle| contains(needle));
let names_language_files = language.is_some()
&& ["file", "files", "script", "scripts"]
.iter()
.any(|needle| contains(needle));
let source_files = names_source_files || names_language_files;
let mut lines = Vec::new();
if contains("table") {
lines.push("response_shape: table".to_string());
}
if source_files {
lines.push("evidence_scope: source_files".to_string());
match language {
Some(pack) => {
if let Ok(extensions) =
crate::api_surface::source_extensions_for(&packs, Some(&pack.name))
{
lines.push(format!("source_extensions: {}", extensions.join(",")));
}
lines.push(format!(
"source_filter: category=source language={}",
pack.name
));
}
None => lines.push("source_filter: category=source".to_string()),
}
lines.push(
"scope_instruction: code/source means registered language source files only; \
exclude documentation, manifests, lockfiles, and other repository metadata \
from the primary evidence set"
.to_string(),
);
}
lines.join("\n")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DispositionLexicon {
pub action: Vec<String>,
pub research: Vec<String>,
pub explain: Vec<String>,
pub question_mark_disposition: PromptDisposition,
}
impl Default for DispositionLexicon {
fn default() -> Self {
Self {
action: [
"implement",
"modify",
"change",
"create",
"write",
"edit",
"delete",
"fix",
"build",
"run ",
"execute",
"commit",
"push",
"open a pr",
"open pr",
"merge",
]
.map(str::to_string)
.to_vec(),
research: [
"research",
"investigate",
"look up",
"find out",
"analyze",
"diagnose",
"audit",
"explore",
"compare",
"largest",
"biggest",
"smallest",
"line count",
"most lines",
"fewest lines",
"longest file",
"shortest file",
]
.map(str::to_string)
.to_vec(),
explain: [
"explain",
"summarize",
"describe",
"what is",
"why ",
"how does",
"how do",
"what are",
"which are",
]
.map(str::to_string)
.to_vec(),
question_mark_disposition: PromptDisposition::Explain,
}
}
}
fn infer_disposition(prompt: &str) -> PromptDisposition {
infer_disposition_with(prompt, &DispositionLexicon::default())
}
fn infer_disposition_with(prompt: &str, lexicon: &DispositionLexicon) -> PromptDisposition {
let lower = prompt.to_ascii_lowercase();
let hit = |needles: &[String]| needles.iter().any(|n| !n.is_empty() && lower.contains(n));
if hit(&lexicon.action) {
return PromptDisposition::Act;
}
if hit(&lexicon.research) {
return PromptDisposition::Research;
}
if hit(&lexicon.explain) {
return PromptDisposition::Explain;
}
if lower.trim_end().ends_with('?') {
return lexicon.question_mark_disposition;
}
PromptDisposition::Act
}
fn extract_decisions(asks: &[AtomicAsk]) -> (Vec<DecisionLock>, bool) {
let mut decisions = Vec::new();
for ask in asks {
let lower = ask.text.to_ascii_lowercase();
let decision =
if needs_operator_decision(&lower) || has_ambiguous_destructive_target(&lower) {
Some((DecisionStatus::Pending, None))
} else {
None
};
if let Some((status, source)) = decision {
if decisions.len() == MAX_CONCRETE_DECISIONS {
return (decisions, true);
}
decisions.push(DecisionLock {
question: ask.text.clone(),
status,
source,
overflow: false,
});
}
}
(decisions, false)
}
fn overflow_decision() -> DecisionLock {
DecisionLock {
question: format!(
"This request exceeds Newt's bounded intake capacity ({MAX_CONCRETE_DECISIONS} decisions or {MAX_ATOMIC_ASKS} asks). Use /new, then start a smaller task before execution."
),
status: DecisionStatus::Pending,
source: None,
overflow: true,
}
}
fn needs_operator_decision(lower: &str) -> bool {
contains_any(
lower,
&[
"either ",
"choose ",
"select ",
"pick ",
"tbd",
"to be decided",
"which option",
"which backend",
"which provider",
],
) || (lower.contains(" or ") && contains_any(lower, &["should", "use", "implement"]))
}
fn has_ambiguous_destructive_target(lower: &str) -> bool {
[
"delete it",
"delete this",
"delete that",
"remove it",
"remove this",
"remove that",
"drop it",
"drop this",
"drop that",
"destroy it",
"destroy this",
"destroy that",
"wipe it",
"wipe this",
"wipe that",
"purge it",
"purge this",
"purge that",
]
.iter()
.any(|phrase| lower.contains(phrase))
}
fn explicit_answer_indices(answer: &str, pending: &[usize]) -> Option<Vec<usize>> {
let answer = answer.trim();
if answer.is_empty() || looks_like_unresolved_question(answer) {
return None;
}
let mut resolved = BTreeSet::new();
for line in answer.lines() {
let line = line.trim();
let line = line.strip_prefix("decision ").unwrap_or(line);
let Some((ordinal, value)) = line.split_once(':') else {
continue;
};
if value.trim().is_empty() {
continue;
}
let Ok(ordinal) = ordinal.trim().parse::<usize>() else {
continue;
};
let pending_ordinal = ordinal.checked_sub(1)?;
if let Some(index) = pending.get(pending_ordinal) {
resolved.insert(*index);
}
}
(resolved.len() == pending.len()).then(|| resolved.into_iter().collect())
}
fn looks_like_unresolved_question(answer: &str) -> bool {
let lower = answer.to_ascii_lowercase();
answer.contains('?')
|| lower.starts_with("what ")
|| lower.starts_with("which ")
|| lower.starts_with("can ")
|| lower.starts_with("could ")
|| lower.starts_with("should ")
}
fn contains_any(text: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| text.contains(needle))
}
fn truncate_chars(text: &str, byte_limit: usize) -> String {
if text.len() <= byte_limit {
return text.to_string();
}
let mut end = byte_limit;
while !text.is_char_boundary(end) {
end -= 1;
}
text[..end].to_string()
}
fn digest_metadata(text: &str) -> Value {
json!({
"digest": blake3::hash(text.as_bytes()).to_hex().to_string(),
"bytes": text.len() as u64,
})
}
#[cfg(test)]
mod tests {
use super::{
DispositionLexicon, PromptDisposition, PromptIntake, MAX_ATOMIC_ASKS,
MAX_CONCRETE_DECISIONS, PROMPT_COMPREHENSION_MODEL_CARD_PREFIX,
};
#[test]
fn action_prompt_is_atomic_and_metadata_is_content_free() {
let secret = "ship the private parser change to /top-secret";
let intake = PromptIntake::analyze(secret);
assert_eq!(intake.disposition(), PromptDisposition::Act);
assert_eq!(intake.atomic_asks().len(), 1);
intake.validate().unwrap();
let artifact_metadata = intake.artifact_metadata();
assert_eq!(
artifact_metadata["schema"],
"prompt_comprehension_manifest_v2"
);
let metadata = artifact_metadata.to_string();
assert!(!metadata.contains("private parser"));
assert!(metadata.contains("atomic_ask_digests"));
let card = intake.model_card();
assert!(card.starts_with(PROMPT_COMPREHENSION_MODEL_CARD_PREFIX));
assert!(!card.contains("private parser"));
}
#[test]
fn empty_headless_input_is_a_bounded_ask_not_act() {
let empty = PromptIntake::analyze(" \n");
assert_eq!(empty.disposition(), PromptDisposition::Ask);
assert_eq!(empty.manifest().pending_decision_count(), 1);
assert!(empty.clarification_batch().contains("non-empty task"));
assert_eq!(
empty
.resolve_with_operator_answer("Explain receipts.")
.disposition(),
PromptDisposition::Explain
);
}
#[test]
fn unresolved_choice_becomes_a_bounded_ask_then_explicit_answer_acts() {
let intake = PromptIntake::analyze(
"Implement either SQLite or Postgres; create the migration and open a PR.",
);
assert_eq!(intake.disposition(), PromptDisposition::Ask);
assert_eq!(intake.atomic_asks().len(), 2);
assert!(intake.clarification_batch().contains("SQLite"));
assert_eq!(
intake
.resolve_with_operator_answer("continue")
.disposition(),
PromptDisposition::Ask,
"an acknowledgement cannot choose a concrete implementation"
);
let resolved = intake.resolve_with_operator_answer("1: SQLite");
assert_eq!(resolved.disposition(), PromptDisposition::Act);
assert_eq!(resolved.manifest().pending_decision_count(), 0);
assert_eq!(
resolved.artifact_metadata()["decision_source_counts"]["operator"],
1
);
resolved.validate().unwrap();
}
#[test]
fn multiple_decisions_require_explicit_ordinal_mapping() {
let intake = PromptIntake::analyze(
"Choose either SQLite or Postgres. Select either staging or production.",
);
assert_eq!(intake.manifest().pending_decision_count(), 2);
assert_eq!(
intake
.resolve_with_operator_answer("SQLite\nproduction")
.disposition(),
PromptDisposition::Ask
);
assert_eq!(
intake
.resolve_with_operator_answer("1: SQLite\n2: production")
.disposition(),
PromptDisposition::Act
);
}
#[test]
fn intake_overflow_remains_ask_and_cannot_be_answered_in_place() {
let prompt = (0..=MAX_CONCRETE_DECISIONS)
.map(|i| format!("Choose either option-{i}-a or option-{i}-b."))
.collect::<Vec<_>>()
.join("\n");
let intake = PromptIntake::analyze(&prompt);
assert_eq!(intake.disposition(), PromptDisposition::Ask);
assert!(
intake
.manifest()
.decisions()
.iter()
.any(super::DecisionLock::is_overflow),
"a truncated decision set must retain an explicit overflow lock"
);
assert_eq!(
intake
.resolve_with_operator_answer("1: option-0-a")
.disposition(),
PromptDisposition::Ask,
"the overflow lock cannot be converted into Act by a partial answer"
);
}
#[test]
fn atomic_ask_overflow_remains_ask() {
let prompt = (0..=MAX_ATOMIC_ASKS)
.map(|i| format!("Implement bounded item {i}."))
.collect::<Vec<_>>()
.join("\n");
let intake = PromptIntake::analyze(&prompt);
assert_eq!(intake.atomic_asks().len(), MAX_ATOMIC_ASKS);
assert_eq!(intake.disposition(), PromptDisposition::Ask);
assert!(intake.manifest().decisions().iter().any(|decision| {
decision.is_overflow() && decision.status() == super::DecisionStatus::Pending
}));
}
#[test]
fn ambiguous_destructive_pronoun_requires_clarification() {
let ambiguous = PromptIntake::analyze("Delete it.");
assert_eq!(ambiguous.disposition(), PromptDisposition::Ask);
let grounded = PromptIntake::analyze("Delete scratch/obsolete.txt.");
assert_eq!(grounded.disposition(), PromptDisposition::Act);
}
#[test]
fn explain_and_research_receive_their_intended_bounded_tool_loops() {
let explain = PromptIntake::analyze("Explain how prompt receipts survive compaction.");
let research = PromptIntake::analyze("Investigate the current compaction behavior.");
assert_eq!(explain.disposition(), PromptDisposition::Explain);
assert_eq!(research.disposition(), PromptDisposition::Research);
assert_eq!(PromptDisposition::Ask.tool_round_limit(40), 0);
assert_eq!(PromptDisposition::Explain.tool_round_limit(40), 40);
assert_eq!(PromptDisposition::Research.tool_round_limit(40), 3);
assert_eq!(PromptDisposition::Plan.tool_round_limit(40), 40);
}
#[test]
fn read_only_attenuation_keeps_model_card_and_artifact_in_sync() {
let mut action = PromptIntake::analyze("Implement the requested parser change.");
assert_eq!(action.disposition(), PromptDisposition::Act);
action.enforce_read_only(PromptDisposition::Plan);
assert_eq!(action.disposition(), PromptDisposition::Plan);
assert!(
action.model_card().contains("disposition: plan"),
"{}",
action.model_card()
);
assert_eq!(
action.artifact_metadata()["schema"],
"prompt_comprehension_manifest_v2"
);
assert_eq!(action.artifact_metadata()["disposition"], "plan");
let mut research = PromptIntake::analyze("Investigate the parser behavior.");
research.enforce_read_only(PromptDisposition::Research);
assert_eq!(
research.disposition(),
PromptDisposition::Research,
"the mode-selected read-only disposition must remain consistent"
);
}
#[test]
fn ordinal_answers_are_relative_to_the_pending_batch() {
assert_eq!(
super::explicit_answer_indices("1: continue", &[4]),
Some(vec![4]),
"the first displayed clarification must resolve the first pending decision, not raw decision zero"
);
assert_eq!(
super::explicit_answer_indices("1: one\n2: two", &[2, 6]),
Some(vec![2, 6])
);
}
const LARGEST_FILES_PROMPT: &str = "What are the 10 largest Rust files in this workspace?";
fn pre_1260_lexicon() -> DispositionLexicon {
DispositionLexicon {
action: [
"implement",
"modify",
"change",
"create",
"write",
"edit",
"delete",
"fix",
"build",
"run ",
"execute",
"commit",
"push",
"open a pr",
"open pr",
"merge",
]
.map(str::to_string)
.to_vec(),
research: [
"research",
"investigate",
"look up",
"find out",
"analyze",
"diagnose",
"audit",
"explore",
"compare",
]
.map(str::to_string)
.to_vec(),
explain: [
"explain",
"summarize",
"describe",
"what is",
"why ",
"how does",
"how do",
]
.map(str::to_string)
.to_vec(),
question_mark_disposition: PromptDisposition::Explain,
}
}
#[test]
fn largest_files_question_classified_explain_via_question_mark_fallback_pre_1260() {
let old = pre_1260_lexicon();
assert_eq!(
super::infer_disposition_with(LARGEST_FILES_PROMPT, &old),
PromptDisposition::Explain,
"under the OLD data the ? fallback alone decided"
);
assert_eq!(
super::infer_disposition_with(LARGEST_FILES_PROMPT.trim_end_matches('?'), &old),
PromptDisposition::Act,
"…and the same prompt minus its ? fell off the cliff to Act"
);
}
#[test]
fn new_defaults_classify_evidence_questions_by_content_not_the_cliff() {
let with_q = PromptIntake::analyze(LARGEST_FILES_PROMPT);
assert_eq!(with_q.disposition(), PromptDisposition::Research);
let without_q =
PromptIntake::analyze("What are the 10 largest Rust files in this workspace");
assert_eq!(
without_q.disposition(),
PromptDisposition::Research,
"content decides; removing the ? no longer flips the disposition"
);
let plural = PromptIntake::analyze("What are the tradeoffs of this design?");
assert_eq!(plural.disposition(), PromptDisposition::Explain);
let act = PromptIntake::analyze("update the release notes for 0.8.0");
assert_eq!(act.disposition(), PromptDisposition::Act);
}
#[test]
fn line_count_questions_classify_research_not_the_cliff() {
let regressed =
PromptIntake::analyze("show me the 10 code files with the highest line counts?");
assert_eq!(
regressed.disposition(),
PromptDisposition::Research,
"line-count question is a Research/evidence turn, not Explain or Act"
);
for prompt in [
"which files have the most lines",
"the longest file in the repo",
"files with the fewest lines",
] {
assert_eq!(
PromptIntake::analyze(prompt).disposition(),
PromptDisposition::Research,
"line-count evidence phrasing → Research: {prompt:?}"
);
}
}
#[test]
fn code_file_prompt_adds_source_scope_without_incident_specific_shape_guessing() {
let intake = PromptIntake::analyze(
"show me the 10 code files with the highest line counts in this repository?",
);
let card = intake.model_card();
assert!(
!card.contains("response_shape:"),
"line-count/ranking keywords must not own presentation policy: {card}"
);
assert!(
card.contains("evidence_scope: source_files"),
"`code files` means language source, not every repository file: {card}"
);
assert!(
card.contains("source_filter: category=source"),
"an unqualified code-file request must use the harness-owned source category: {card}"
);
assert!(
card.contains("exclude documentation, manifests, lockfiles"),
"the steering must name the observed false-positive classes: {card}"
);
assert!(
!card.contains("highest")
&& !card.contains("longest")
&& !card.contains("most lines")
&& !card.contains("line/size rankings")
&& !card.contains("code=true"),
"the model card must carry a general source refinement, not an incident lexicon: {card}"
);
}
#[test]
fn explicit_rust_table_prompt_steers_rs_filter_and_gfm_table() {
let intake = PromptIntake::analyze(
"can you give me a table of the rust files with the longest line counts instead?",
);
let card = intake.model_card();
assert!(card.contains("response_shape: table"), "{card}");
assert!(card.contains("evidence_scope: source_files"), "{card}");
assert!(
card.contains("source_extensions: rs"),
"Rust must resolve through the language-pack data to its source extension: {card}"
);
assert!(
card.contains("source_filter: category=source language=rust"),
"the model needs the concrete harness filter, not just a language label: {card}"
);
}
#[test]
fn ordinary_prompt_gets_no_incident_specific_refinement() {
let card = PromptIntake::analyze("explain ownership briefly").model_card();
assert!(!card.contains("response_format:"), "{card}");
assert!(!card.contains("response_shape:"), "{card}");
assert!(!card.contains("evidence_scope:"), "{card}");
assert!(!card.contains("source_filter:"), "{card}");
let comfortable =
PromptIntake::analyze("make this interface more comfortable").model_card();
assert!(
!comfortable.contains("response_shape:"),
"presentation inference must not match `table` inside another word: {comfortable}"
);
}
#[test]
fn lexicon_overrides_drive_inference_table_driven() {
let custom = DispositionLexicon {
explain: vec!["kerfuffle".to_string()],
question_mark_disposition: PromptDisposition::Research,
..DispositionLexicon::default()
};
for (prompt, want) in [
("tell me about the kerfuffle", PromptDisposition::Explain),
("what is a monad?", PromptDisposition::Research),
("fix the kerfuffle", PromptDisposition::Act),
("status report", PromptDisposition::Act),
] {
assert_eq!(
super::infer_disposition_with(prompt, &custom),
want,
"{prompt:?}"
);
}
}
#[test]
fn analyze_with_applies_the_lexicon_and_keeps_ask_precedence() {
let lex = DispositionLexicon {
research: vec!["kerfuffle".to_string()],
..DispositionLexicon::default()
};
let intake = PromptIntake::analyze_with("tell me about the kerfuffle", &lex);
assert_eq!(intake.disposition(), PromptDisposition::Research);
let asky = PromptIntake::analyze_with(
"Investigate either the kerfuffle or the brouhaha; compare them.",
&lex,
);
if asky.manifest().pending_decision_count() > 0 {
assert_eq!(asky.disposition(), PromptDisposition::Ask);
}
let empty = PromptIntake::analyze_with(" ", &lex);
assert_eq!(empty.disposition(), PromptDisposition::Ask);
}
}