objectiveai_sdk/functions/inventions/path.rs
1//! Path encoding for invention task hierarchies.
2//!
3//! ── HOW FUNCTION NAMES IDENTIFY DESCENDANTS ─────────────────────────
4//!
5//! Every invention task gets a `name` of the form `<base-name>-<suffix>`
6//! where `<suffix>` is the bijective base-255 encoding of the task's
7//! position in the invention tree, rendered as base62. When a child
8//! task is being created, `state::child_name` looks at the parent's
9//! name, decides whether the trailing `-<segment>` is already a path
10//! encoding, and either:
11//!
12//! - extends that path in place (real path detected), or
13//! - appends a *new* path segment (no path detected; the segment
14//! belongs to the user's original name, e.g. `-v`, `-vii`,
15//! `-final`, `-prod`).
16//!
17//! ── THE INVARIANT YOU MUST NOT BREAK ────────────────────────────────
18//!
19//! A path suffix is **always exactly [`PATH_SUFFIX_LEN`] base62
20//! characters**. Detection in `child_name` / `reindex_name` requires
21//! BOTH:
22//!
23//! 1. the trailing `-<segment>` is exactly `PATH_SUFFIX_LEN` chars
24//! long, AND
25//! 2. it parses successfully via [`b62_to_path`] (i.e. it is valid
26//! base62 AND decodes to a structurally valid path).
27//!
28//! Both conditions are required. Length alone isn't enough — a 6-char
29//! string with a non-base62 character would falsely qualify. A
30//! successful parse alone isn't enough — that's the bug that ate
31//! `-v` and `-vii` (both happily decode as base62 integers and then
32//! as paths, because every `[1, MAX]` u128 round-trips back into
33//! `Vec<u64>`).
34//!
35//! ── THINGS YOU (FUTURE CLAUDE) MUST NEVER DO ────────────────────────
36//!
37//! * DO NOT change `path_to_b62` to pad / fix-width / left-pad with
38//! zeros / append marker chars / use a sigil. The encoder is
39//! stable and produces variable-length raw base62 on purpose.
40//! Existing function names in the wild were written with this
41//! encoder; changing it desynchronizes detection from generation.
42//! * DO NOT switch from base62 to hex / base32 / base64 / decimal /
43//! anything else. The detection check assumes base62 alphabet.
44//! * DO NOT change [`PATH_SUFFIX_LEN`] casually. It is part of the
45//! on-the-wire convention.
46//! * DO NOT add a separate "discriminator" character (like a
47//! leading `_` or doubled `--`) to disambiguate path suffixes.
48//! The length+alphabet check is the discriminator.
49//! * DO NOT relax the detection: removing either the length check
50//! or the parse check brings back the `-v` / `-vii` bug.
51//!
52//! If a future requirement seems to demand changing any of the above,
53//! stop and write up the full implication chain (existing names,
54//! external readers, JSON schemas, agent prompts, mock data) before
55//! touching this file.
56
57use std::fmt::Display;
58
59pub trait PathElement: Copy + Into<u128> + TryFrom<u128> + Ord + Display {}
60
61macro_rules! impl_path_element {
62 ($($t:ty),*) => {
63 $(impl PathElement for $t {})*
64 };
65}
66
67impl_path_element!(u8, u16, u32, u64, u128);
68
69const MAX_LEN: usize = 16;
70const MAX_VAL: u128 = 254;
71
72/// The on-the-wire length, in base62 characters, of a path suffix in a
73/// function name. Detection (in `state::child_name` /
74/// `state::reindex_name`) requires the trailing `-<segment>` to be
75/// **exactly this many** characters before even attempting to parse it
76/// as a path. See the module docs for the full invariant.
77pub const PATH_SUFFIX_LEN: usize = 6;
78
79fn validate_path<T: PathElement>(path: &[T]) -> Result<(), String> {
80 if path.len() > MAX_LEN {
81 return Err(format!(
82 "path length {} exceeds maximum of {MAX_LEN}",
83 path.len()
84 ));
85 }
86 for (i, &v) in path.iter().enumerate() {
87 if v.into() > MAX_VAL {
88 return Err(format!("path[{i}] value {v} exceeds maximum of {MAX_VAL}"));
89 }
90 }
91 Ok(())
92}
93
94/// Bijective base-255 encoding: each value v is stored as v+1 (digits 1-255).
95/// Since there is no zero digit, different-length paths always produce
96/// different u128 values (e.g. [] → 0, [0] → 1, [0,0] → 256).
97pub fn path_to_u128<T: PathElement>(path: &[T]) -> Result<u128, String> {
98 validate_path(path)?;
99 let mut result: u128 = 0;
100 for &v in path {
101 result = result * 255 + v.into() + 1;
102 }
103 Ok(result)
104}
105
106pub fn u128_to_path<T: PathElement>(mut encoded: u128) -> Result<Vec<T>, String> {
107 let mut path = Vec::new();
108 while encoded > 0 {
109 encoded -= 1;
110 let digit = encoded % 255;
111 path.push(
112 T::try_from(digit)
113 .map_err(|_| format!("value {digit} out of range for target type"))?,
114 );
115 encoded /= 255;
116 }
117 if path.len() > MAX_LEN {
118 return Err(format!(
119 "decoded length {} exceeds maximum of {MAX_LEN}",
120 path.len()
121 ));
122 }
123 path.reverse();
124 Ok(path)
125}
126
127pub fn u128_to_b62(v: u128) -> String {
128 base62::encode(v)
129}
130
131pub fn b62_to_u128(s: &str) -> Result<u128, String> {
132 base62::decode(s).map_err(|e| format!("invalid base62: {e}"))
133}
134
135pub fn path_to_b62<T: PathElement>(path: &[T]) -> Result<String, String> {
136 path_to_u128(path).map(u128_to_b62)
137}
138
139pub fn b62_to_path<T: PathElement>(s: &str) -> Result<Vec<T>, String> {
140 b62_to_u128(s).and_then(u128_to_path)
141}