1use crate::ir::WarbleIr;
5use serde::Serialize;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum RenderFlavor {
15 Programmatic,
16 Prompt,
17}
18
19pub const DEFAULT_RENDER_FLAVOR: RenderFlavor = RenderFlavor::Programmatic;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "kebab-case")]
31pub enum ContextInjectionMode {
32 SchemaOnly,
33}
34
35pub const DEFAULT_CONTEXT_INJECTION: ContextInjectionMode = ContextInjectionMode::SchemaOnly;
36
37impl ContextInjectionMode {
38 pub fn as_str(&self) -> &'static str {
39 match self {
40 ContextInjectionMode::SchemaOnly => "schema-only",
41 }
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ContextInjection {
48 mode: ContextInjectionMode,
49 schema_digest: String,
50 introspected: bool,
52 absent: bool,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
58pub struct ContextInjectionReport {
59 pub mode: &'static str,
60 pub schema_digest_fingerprint: String,
61 pub knowledge_fingerprint: Option<String>,
62 pub knowledge_chars: usize,
63}
64
65impl ContextInjection {
66 pub fn from_ir(ir: &WarbleIr, mode: ContextInjectionMode) -> Self {
69 let introspected = ir.context_binding.resolved.is_some();
70 let schema_digest = build_schema_digest(ir.context_binding.resolved.as_ref());
71 Self {
72 mode,
73 schema_digest,
74 introspected,
75 absent: ir.context_binding.absent,
76 }
77 }
78
79 pub(crate) fn absent(&self) -> bool {
80 self.absent
81 }
82
83 pub fn mode(&self) -> ContextInjectionMode {
84 self.mode
85 }
86
87 pub fn introspected(&self) -> bool {
91 self.introspected
92 }
93
94 pub fn prompt_section(&self) -> String {
95 if self.absent {
96 return String::new();
97 }
98 let knowledge = match self.mode {
99 ContextInjectionMode::SchemaOnly if !self.introspected => "Knowledge rules are intentionally excluded for this run. Do NOT call a context-instruction tool or read project knowledge files.".to_string(),
102 ContextInjectionMode::SchemaOnly => "Knowledge rules are intentionally excluded for this run. Do NOT call a context-instruction tool or read project knowledge files; answer from the injected schema and the question only.".to_string(),
103 };
104 if !self.introspected {
108 return format!(
109 "## Injected context\n\nContext injection mode: `{}`. The bound semantic layer was NOT introspected here — it is held elsewhere. You therefore know NOTHING about which models, metrics or dimensions exist. Never tell the user that something does not exist, and never rule a question out, on the strength of this absence; put the question to whatever tool answers it and report what comes back.\n\n{}",
110 self.mode.as_str(),
111 knowledge
112 );
113 }
114 format!(
115 "## Injected context\n\nContext injection mode: `{}`. Use this compiled schema digest before calling a semantic-introspection tool; introspect only when the question needs details absent from the digest.\n\n<schema_digest>\n{}\n</schema_digest>\n\n{}",
116 self.mode.as_str(),
117 self.schema_digest,
118 knowledge
119 )
120 }
121
122 pub fn report(&self) -> ContextInjectionReport {
123 ContextInjectionReport {
124 mode: if self.absent {
125 "none"
126 } else {
127 self.mode.as_str()
128 },
129 schema_digest_fingerprint: fingerprint(&self.schema_digest),
130 knowledge_fingerprint: None,
133 knowledge_chars: 0,
134 }
135 }
136}
137
138fn fingerprint(value: &str) -> String {
141 let mut hash = 0xcbf29ce484222325_u64;
142 for byte in value.as_bytes() {
143 hash ^= u64::from(*byte);
144 hash = hash.wrapping_mul(0x100000001b3);
145 }
146 format!("fnv1a64:{hash:016x}")
147}
148
149fn canonical_json(value: &serde_json::Value) -> String {
150 match value {
151 serde_json::Value::Null => "null".to_string(),
152 serde_json::Value::Bool(v) => v.to_string(),
153 serde_json::Value::Number(v) => v.to_string(),
154 serde_json::Value::String(v) => serde_json::to_string(v).expect("string serializes"),
155 serde_json::Value::Array(values) => format!(
156 "[{}]",
157 values
158 .iter()
159 .map(canonical_json)
160 .collect::<Vec<_>>()
161 .join(",")
162 ),
163 serde_json::Value::Object(values) => {
164 let mut entries = values.iter().collect::<Vec<_>>();
165 entries.sort_by_key(|(key, _)| *key);
166 format!(
167 "{{{}}}",
168 entries
169 .into_iter()
170 .map(|(key, value)| format!(
171 "{}:{}",
172 serde_json::to_string(key).expect("key serializes"),
173 canonical_json(value)
174 ))
175 .collect::<Vec<_>>()
176 .join(",")
177 )
178 }
179 }
180}
181
182fn sorted_entries(value: Option<&serde_json::Value>, key: &str) -> Vec<String> {
183 let mut entries = value
184 .and_then(|v| v.get(key))
185 .and_then(serde_json::Value::as_array)
186 .into_iter()
187 .flatten()
188 .map(|entry| match entry {
189 serde_json::Value::String(s) => s.clone(),
190 other => canonical_json(other),
191 })
192 .collect::<Vec<_>>();
193 entries.sort();
194 entries
195}
196
197fn digest_line(label: &str, entries: Vec<String>) -> String {
198 if entries.is_empty() {
199 format!("{label}: (none)")
200 } else {
201 format!("{label}: {}", entries.join(", "))
202 }
203}
204
205fn build_schema_digest(resolved: Option<&serde_json::Value>) -> String {
206 let lineage = resolved
207 .and_then(|v| v.get("lineage"))
208 .map(canonical_json)
209 .unwrap_or_else(|| "unavailable".to_string());
210 [
211 digest_line("Models", sorted_entries(resolved, "models")),
212 digest_line("Metrics", sorted_entries(resolved, "metrics")),
213 digest_line("Dimensions", sorted_entries(resolved, "dimensions")),
214 digest_line(
215 "Time dimensions",
216 sorted_entries(resolved, "time_dimensions"),
217 ),
218 format!("Lineage: {lineage}"),
219 ]
220 .join("\n")
221}
222
223impl RenderFlavor {
224 pub fn as_str(&self) -> &'static str {
225 match self {
226 RenderFlavor::Programmatic => "programmatic",
227 RenderFlavor::Prompt => "prompt",
228 }
229 }
230
231 pub fn parse(value: &str) -> Option<RenderFlavor> {
232 match value {
233 "programmatic" => Some(RenderFlavor::Programmatic),
234 "prompt" => Some(RenderFlavor::Prompt),
235 _ => None,
236 }
237 }
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
248pub enum HybridRealization {
249 #[default]
250 BashScript,
251 McpServer,
252}
253
254impl HybridRealization {
255 pub fn parse(value: &str) -> Option<HybridRealization> {
256 match value {
257 "bash-script" => Some(HybridRealization::BashScript),
258 "mcp-server" => Some(HybridRealization::McpServer),
259 _ => None,
260 }
261 }
262}