Skip to main content

heddle_object_model/compact/
state.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use super::{
4    Result,
5    dictionary::{PrincipalKey, StateDictionaries},
6    invalid,
7    io::Writer,
8    limits::MAX_COMPACT_STATE_COUNT,
9};
10use crate::object::{ChangeLineageKind, State};
11
12pub(super) const STATE_MAGIC_V1: &[u8; 4] = b"HCS1";
13pub(super) const STATE_MAGIC: &[u8; 4] = b"HCS2";
14
15/// Whether `bytes` begin with a compact-state frame discriminator.
16pub fn is_state_frame(bytes: &[u8]) -> bool {
17    bytes.starts_with(STATE_MAGIC) || bytes.starts_with(STATE_MAGIC_V1)
18}
19
20/// Encode states as lossless columns, omitting only the derivable state id.
21///
22/// Format-4 agent states keep their accepted stored id through
23/// [`crate::object::State::accepts_stored_id`] after decode; the frame
24/// stays HCS2.
25pub fn encode_state_frame(states: &[State]) -> Result<Vec<u8>> {
26    encode_state_frame_versioned(states, STATE_MAGIC)
27}
28
29#[cfg(test)]
30pub(super) fn encode_state_frame_hcs1(states: &[State]) -> Result<Vec<u8>> {
31    encode_state_frame_versioned(states, STATE_MAGIC_V1)
32}
33
34fn encode_state_frame_versioned(states: &[State], magic: &[u8; 4]) -> Result<Vec<u8>> {
35    if states.len() > MAX_COMPACT_STATE_COUNT {
36        return Err(invalid(format!(
37            "state frame count {} exceeds maximum {MAX_COMPACT_STATE_COUNT}",
38            states.len()
39        )));
40    }
41    let dictionaries = StateDictionaries::from_states(states);
42    let mut output = Writer::new(magic);
43    output.put_u64(states.len() as u64);
44    encode_dictionaries(&mut output, &dictionaries, magic == STATE_MAGIC);
45    encode_structure(&mut output, states);
46    encode_attribution(&mut output, states, &dictionaries);
47    encode_intent_and_verification(&mut output, states)?;
48    encode_timestamps(&mut output, states);
49    encode_fidelity(&mut output, states, &dictionaries);
50    encode_lineage(&mut output, states);
51    Ok(output.finish())
52}
53
54fn encode_structure(output: &mut Writer, states: &[State]) {
55    for state in states {
56        output.put_fixed(state.change_id.as_bytes());
57    }
58    for state in states {
59        output.put_fixed(state.tree.as_bytes());
60    }
61    for state in states {
62        output.put_u64(state.parents.len() as u64);
63        for parent in &state.parents {
64            output.put_fixed(parent.as_bytes());
65        }
66    }
67}
68
69fn encode_attribution(output: &mut Writer, states: &[State], dictionaries: &StateDictionaries) {
70    for state in states {
71        output.put_u64(dictionaries.principal_index(&state.attribution.principal));
72    }
73    for state in states {
74        output.put_u64(
75            state
76                .attribution
77                .agent
78                .as_ref()
79                .map(|agent| dictionaries.agent_index(agent) + 1)
80                .unwrap_or(0),
81        );
82    }
83}
84
85fn encode_intent_and_verification(output: &mut Writer, states: &[State]) -> Result<()> {
86    for state in states {
87        output.put_optional_bytes(state.intent.as_deref().map(str::as_bytes));
88    }
89    for state in states {
90        put_optional_f32(output, state.confidence);
91    }
92    for state in states {
93        encode_verification(output, state)?;
94    }
95    for state in states {
96        output.put_u8(state.status.to_byte());
97    }
98    Ok(())
99}
100
101fn encode_timestamps(output: &mut Writer, states: &[State]) {
102    let mut previous = 0i64;
103    for (index, state) in states.iter().enumerate() {
104        let seconds = state.created_at.timestamp();
105        output.put_i64(if index == 0 {
106            seconds
107        } else {
108            seconds - previous
109        });
110        output.put_u64(u64::from(state.created_at.timestamp_subsec_nanos()));
111        previous = seconds;
112    }
113    for state in states {
114        match state.authored_at {
115            Some(timestamp) => {
116                output.put_u8(1);
117                output.put_i64(timestamp.timestamp() - state.created_at.timestamp());
118                output.put_u64(u64::from(timestamp.timestamp_subsec_nanos()));
119            }
120            None => output.put_u8(0),
121        }
122    }
123    for state in states {
124        output.put_i64(i64::from(state.authored_tz_offset));
125        output.put_i64(i64::from(state.committer_tz_offset));
126    }
127}
128
129fn encode_fidelity(output: &mut Writer, states: &[State], dictionaries: &StateDictionaries) {
130    for state in states {
131        match state.provenance {
132            Some(hash) => {
133                output.put_u8(1);
134                output.put_fixed(hash.as_bytes());
135            }
136            None => output.put_u8(0),
137        }
138    }
139    for state in states {
140        output.put_u64(
141            state
142                .committer
143                .as_ref()
144                .map(|value| dictionaries.principal_index(value) + 1)
145                .unwrap_or(0),
146        );
147    }
148    for state in states {
149        output.put_optional_bytes(state.raw_message.as_deref());
150    }
151    for state in states {
152        output.put_u64(state.extra_headers.len() as u64);
153        for (name, value) in &state.extra_headers {
154            output.put_bytes(name);
155            output.put_bytes(value);
156        }
157    }
158    for state in states {
159        output.put_bool(state.git_lossy);
160    }
161}
162
163fn encode_lineage(output: &mut Writer, states: &[State]) {
164    for state in states {
165        output.put_u64(state.lineage.len() as u64);
166        for lineage in &state.lineage {
167            output.put_u8(lineage_kind_tag(lineage.kind));
168            output.put_fixed(lineage.source_change.as_bytes());
169            output.put_fixed(lineage.source_state.as_bytes());
170        }
171    }
172}
173
174fn encode_verification(output: &mut Writer, state: &State) -> Result<()> {
175    let Some(value) = &state.verification else {
176        output.put_u8(0);
177        return Ok(());
178    };
179    output.put_u8(1);
180    put_optional_bool(output, value.tests_passed);
181    put_optional_u32(output, value.tests_failed);
182    put_optional_f32(output, value.coverage_pct);
183    put_optional_f32(output, value.coverage_delta);
184    put_optional_u32(output, value.lint_warnings);
185    output.put_u64(value.custom.len() as u64);
186    for (key, json) in &value.custom {
187        output.put_bytes(key.as_bytes());
188        output.put_bytes(&rmp_serde::to_vec(json)?);
189    }
190    Ok(())
191}
192
193fn encode_dictionaries(
194    output: &mut Writer,
195    dictionaries: &StateDictionaries,
196    include_cursor_fields: bool,
197) {
198    output.put_u64(dictionaries.principals.len() as u64);
199    for PrincipalKey(name, email) in &dictionaries.principals {
200        output.put_bytes(name);
201        output.put_bytes(email);
202    }
203    output.put_u64(dictionaries.agents.len() as u64);
204    for agent in &dictionaries.agents {
205        output.put_bytes(&agent.provider);
206        output.put_bytes(&agent.model);
207        output.put_optional_bytes(agent.session_id.as_deref());
208        output.put_optional_bytes(agent.segment_id.as_deref());
209        output.put_optional_bytes(agent.policy_id.as_deref());
210        if include_cursor_fields {
211            output.put_optional_bytes(agent.thought_level.as_deref());
212            output.put_optional_bytes(agent.parent.as_deref());
213        }
214    }
215}
216
217fn put_optional_bool(output: &mut Writer, value: Option<bool>) {
218    output.put_u8(match value {
219        None => 0,
220        Some(false) => 1,
221        Some(true) => 2,
222    });
223}
224
225fn put_optional_u32(output: &mut Writer, value: Option<u32>) {
226    output.put_u64(value.map(u64::from).map(|value| value + 1).unwrap_or(0));
227}
228
229fn put_optional_f32(output: &mut Writer, value: Option<f32>) {
230    match value {
231        Some(value) => {
232            output.put_u8(1);
233            output.put_fixed(&value.to_le_bytes());
234        }
235        None => output.put_u8(0),
236    }
237}
238
239pub(super) fn lineage_kind_tag(kind: ChangeLineageKind) -> u8 {
240    match kind {
241        ChangeLineageKind::CherryPick => 1,
242        ChangeLineageKind::Collapse => 2,
243        ChangeLineageKind::Revert => 3,
244        ChangeLineageKind::GitProjection => 4,
245    }
246}