Skip to main content

prism/
model.rs

1// Copyright 2024-2026 Reflective Labs
2
3use crate::engine::FeatureVector;
4use crate::provenance::PRISM_PROVENANCE;
5use burn::{
6    nn::{Linear, LinearConfig, Relu},
7    prelude::*,
8    tensor::{Tensor, backend::Backend},
9};
10use converge_pack::{
11    AgentEffect, Context, ContextKey, Provenance, ProvenanceSource, Suggestor, TextPayload,
12};
13
14// Re-defining for now if not public in engine, strictly we should move to lib or common
15// But for this example we assume we can deserialize into this struct.
16
17/// Simple MLP Model
18#[derive(Module, Debug)]
19pub struct Model<B: Backend> {
20    fc1: Linear<B>,
21    fc2: Linear<B>,
22    activation: Relu,
23}
24
25impl<B: Backend> Model<B> {
26    pub fn new(device: &B::Device) -> Self {
27        // Initialize with default config for demo
28        let config = ModelConfig::new(3, 16, 1);
29        config.init(device)
30    }
31
32    pub fn forward(&self, input: Tensor<B, 2>) -> Tensor<B, 2> {
33        let x = self.fc1.forward(input);
34        let x = self.activation.forward(x);
35        self.fc2.forward(x)
36    }
37}
38
39#[derive(Config, Debug)]
40pub struct ModelConfig {
41    input_size: usize,
42    hidden_size: usize,
43    output_size: usize,
44}
45
46impl ModelConfig {
47    pub fn init<B: Backend>(&self, device: &B::Device) -> Model<B> {
48        Model {
49            fc1: LinearConfig::new(self.input_size, self.hidden_size).init(device),
50            fc2: LinearConfig::new(self.hidden_size, self.output_size).init(device),
51            activation: Relu::new(),
52        }
53    }
54}
55
56#[derive(Debug, Default)]
57pub struct InferenceAgent {
58    // in real app, model might be Arc<Mutex<Model>> or just loaded
59    // For demo we instantiate on fly or would hold it.
60    // Burn models are cheap to clone if weights are Arc.
61    // For this demo, we won't hold the model in the struct to avoid generic complexity in the Suggestor trait object,
62    // or we use a concrete backend like NdArrayBackend.
63}
64
65impl InferenceAgent {
66    pub fn new() -> Self {
67        Self {}
68    }
69}
70
71#[async_trait::async_trait]
72impl Suggestor for InferenceAgent {
73    fn name(&self) -> &'static str {
74        "InferenceAgent (Burn)"
75    }
76
77    fn dependencies(&self) -> &[ContextKey] {
78        &[ContextKey::Proposals]
79    }
80
81    fn accepts(&self, ctx: &dyn Context) -> bool {
82        // Run if there are proposals (features) but no hypothesis yet
83        ctx.has(ContextKey::Proposals) && !ctx.has(ContextKey::Hypotheses)
84    }
85
86    fn provenance(&self) -> Provenance {
87        PRISM_PROVENANCE.provenance()
88    }
89
90    async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
91        // 1. Find the feature proposal
92        // In reality, filtered by typed Prism provenance plus feature metadata.
93        let _proposals = ctx.get(ContextKey::Proposals); // wait, ctx.get returns Fact, but proposals are ProposedFacts?
94        // Ah, ctx.get(ContextKey) returns FACTs (promoted).
95        // If FeatureAgent emits PROPOSALS, they are in `ContextKey::Proposals`?
96        // Wait, ContextKey::Proposals is a key where Validated Proposals might live?
97        // OR does FeatureAgent emit *Facts* directly if trusted?
98
99        // In the `engine.rs` implementation I sent `ProposedFact` with key `ContextKey::Proposals`.
100        // If they are not promoted to Facts, they are not in `ctx.get()`.
101        // `Context` only stores `facts`.
102        // Proposals usually sit in a queue in the Engine or are added to Context if Key::Proposals is a storage for them?
103        // Looking at `ContextKey` definition: "Internal storage for proposed facts before validation."
104        // So they ARE stored as FACTS under the key `Proposals` if the system works that way?
105        // OR `ProposedFact`s are converted to `Fact`s by the engine.
106        // `ProposedFact::try_from` converts to `Fact`.
107        // If the engine accepts the proposal, it adds it as a Fact.
108
109        // Let's assume the engine validated it and stored it.
110        // So we look for Facts in `ContextKey::Proposals`?
111        // Actually, normally `Proposals` key is for... proposals.
112        // But `FeatureAgent` intended to propose `context.key = Proposals`?
113        // No, `FeatureAgent` sent `proposal.key = Proposals`.
114
115        // Let's assume we find the features in `ContextKey::Proposals` (as stored Facts).
116
117        // We iterate and find one we haven't processed? For now just take the first.
118
119        // This logic is simplified for demo.
120
121        let facts = ctx.get(ContextKey::Proposals);
122        if facts.is_empty() {
123            return AgentEffect::empty();
124        }
125
126        // 2. Read typed features
127        let features = match facts[0].payload::<FeatureVector>() {
128            Some(features) => features,
129            None => return AgentEffect::empty(),
130        };
131
132        // 3. Run Inference (Burn)
133        type B = burn::backend::NdArray;
134        let device = Default::default();
135        let model: Model<B> = ModelConfig::new(3, 16, 1).init(&device);
136
137        let input = Tensor::<B, 1>::from_floats(features.data.as_slice(), &device)
138            .reshape([features.shape[0], features.shape[1]]);
139
140        let output = model.forward(input);
141
142        // 4. Emit Hypothesis
143        let values: Vec<f32> = output.into_data().to_vec::<f32>().unwrap_or_default();
144        let prediction = values[0]; // Assume single output
145
146        let hypo_content = format!("Prediction: {:.4} (based on {})", prediction, facts[0].id());
147
148        let hypothesis = PRISM_PROVENANCE.proposed_fact(
149            ContextKey::Hypotheses,
150            format!("hypo-{}", facts[0].id()),
151            TextPayload::new(hypo_content),
152        );
153
154        AgentEffect::with_proposal(hypothesis)
155    }
156}
157
158/// Run batch inference on a [`FeatureVector`] using a configured model.
159///
160/// Abstracts Burn internals: the caller provides a [`ModelConfig`] and
161/// a [`FeatureVector`] (shape [n, input_size]), and receives a `Vec<f32>`
162/// of per-sample predictions.
163///
164/// Uses the `NdArray` backend internally.
165pub fn run_batch_inference(
166    config: &ModelConfig,
167    features: &FeatureVector,
168) -> anyhow::Result<Vec<f32>> {
169    type B = burn::backend::NdArray;
170    let device = Default::default();
171    let model: Model<B> = config.init(&device);
172
173    let n = features.rows();
174    let input = Tensor::<B, 1>::from_floats(features.data.as_slice(), &device)
175        .reshape([n, config.input_size]);
176    let output = model.forward(input);
177    let values: Vec<f32> = output.into_data().to_vec::<f32>().unwrap_or_default();
178    Ok(values)
179}