diff --git a/CURRENT_PLAN.md b/CURRENT_PLAN.md index d8dd9d5c..0a285220 100644 --- a/CURRENT_PLAN.md +++ b/CURRENT_PLAN.md @@ -486,10 +486,11 @@ Example for confidence: ```rust fn __qa_confidence_type_ir() -> ::dspy_rs::TypeIR { - let ty = ::baml_type_ir(); - ::dspy_rs::legacy_bridge::with_constraints(ty, vec![ + let mut ty = ::dspy_rs::baml_type_ir::(); + ty.meta_mut().constraints.extend(vec![ ::dspy_rs::Constraint::new_check("range", "this >= 0.0 && this <= 1.0"), - ]) + ]); + ty } ``` diff --git a/crates/bamltype-derive/src/lib.rs b/crates/bamltype-derive/src/lib.rs index 4f20c439..a34963ed 100644 --- a/crates/bamltype-derive/src/lib.rs +++ b/crates/bamltype-derive/src/lib.rs @@ -169,7 +169,7 @@ fn resolve_runtime_crate() -> syn::Result { } if let Some(dspy_path) = find_crate_path("dspy-rs") { - return Ok(syn::parse_quote!(#dspy_path::bamltype)); + return Ok(syn::parse_quote!(#dspy_path::__macro_support::bamltype)); } Err(syn::Error::new( @@ -725,7 +725,7 @@ fn normalize_field_attrs( let with_expr = quote! { &#runtime_crate::facet_ext::WithAdapterFns { type_ir: || <#adapter as #runtime_crate::adapters::FieldCodec<#field_ty>>::type_ir(), - register: |reg| <#adapter as #runtime_crate::adapters::FieldCodec<#field_ty>>::register(reg), + register: |ctx| <#adapter as #runtime_crate::adapters::FieldCodec<#field_ty>>::register(ctx), apply: |partial, value, path| { let converted = <#adapter as #runtime_crate::adapters::FieldCodec<#field_ty>>::try_from_baml(value, path)?; partial.set(converted).map_err(|err| { diff --git a/crates/bamltype/src/adapters.rs b/crates/bamltype/src/adapters.rs index acfcc856..01643f19 100644 --- a/crates/bamltype/src/adapters.rs +++ b/crates/bamltype/src/adapters.rs @@ -5,11 +5,40 @@ use baml_types::{BamlValue, TypeIR}; use crate::BamlConvertError; /// Registry type used by field codecs to register custom schema artifacts. +/// +/// This is the canonical registry surface for facet-level codec extensions. pub type AdapterSchemaRegistry = crate::schema_registry::SchemaRegistry; +/// Backward-friendly alias clarifying registry ownership at the codec boundary. +pub type FieldCodecSchemaRegistry = AdapterSchemaRegistry; + +/// Context passed to `FieldCodec::register`. +#[derive(Debug)] +pub struct FieldCodecRegisterContext<'a> { + pub registry: &'a mut AdapterSchemaRegistry, + pub owner_internal_name: Option<&'a str>, + pub field_name: Option<&'a str>, + pub rendered_field_name: Option<&'a str>, + pub variant_name: Option<&'a str>, + pub rendered_variant_name: Option<&'a str>, +} + +impl<'a> FieldCodecRegisterContext<'a> { + pub fn new(registry: &'a mut AdapterSchemaRegistry) -> Self { + Self { + registry, + owner_internal_name: None, + field_name: None, + rendered_field_name: None, + variant_name: None, + rendered_variant_name: None, + } + } +} + /// Field-level conversion + schema representation hook. pub trait FieldCodec { fn type_ir() -> TypeIR; - fn register(_reg: &mut AdapterSchemaRegistry) {} + fn register(_ctx: FieldCodecRegisterContext<'_>) {} fn try_from_baml(value: BamlValue, path: Vec) -> Result; } diff --git a/crates/bamltype/src/facet_ext.rs b/crates/bamltype/src/facet_ext.rs index 0622b77c..8b4ef8d3 100644 --- a/crates/bamltype/src/facet_ext.rs +++ b/crates/bamltype/src/facet_ext.rs @@ -2,7 +2,7 @@ use baml_types::{BamlValue, TypeIR}; -use crate::adapters::AdapterSchemaRegistry; +use crate::adapters::FieldCodecRegisterContext; use crate::runtime::BamlConvertError; /// Field-level adapter application function. @@ -12,6 +12,9 @@ pub type AdapterApplyFn = fn( Vec, ) -> Result, BamlConvertError>; +/// Field-level adapter schema registration function. +pub type AdapterRegisterFn = for<'a> fn(FieldCodecRegisterContext<'a>); + /// Runtime hooks for `#[baml(with = "...")]`. #[derive(Clone, Copy, Debug, facet::Facet)] #[facet(opaque)] @@ -19,7 +22,7 @@ pub struct WithAdapterFns { /// Schema type representation callback. pub type_ir: fn() -> TypeIR, /// Schema registration callback. - pub register: fn(&mut AdapterSchemaRegistry), + pub register: AdapterRegisterFn, /// Value conversion callback. pub apply: AdapterApplyFn, } diff --git a/crates/bamltype/src/lib.rs b/crates/bamltype/src/lib.rs index 046535a4..e8a696dd 100644 --- a/crates/bamltype/src/lib.rs +++ b/crates/bamltype/src/lib.rs @@ -57,8 +57,8 @@ pub use convert::{ConvertError, from_baml_value, from_baml_value_with_flags, to_ mod runtime; pub use runtime::{ - BamlConvertError, BamlTypeInternal, BamlTypeTrait, BamlValueConvert, ToBamlValue, - default_streaming_behavior, get_field, + BamlConvertError, baml_internal_name, baml_type_ir, default_streaming_behavior, get_field, + to_baml_value_lossy, try_from_baml_value, }; pub mod adapters; @@ -96,25 +96,33 @@ pub trait BamlSchema: for<'a> facet::Facet<'a> { } /// Runtime trait for types that expose BAML schema + conversion entry points. -pub trait BamlType: BamlTypeInternal + BamlValueConvert + Sized + 'static { +pub trait BamlType: Sized + 'static { fn baml_output_format() -> &'static OutputFormatContent; + fn baml_internal_name() -> &'static str; + fn baml_type_ir() -> TypeIR; + fn try_from_baml_value(value: BamlValue) -> Result; + fn to_baml_value(&self) -> BamlValue; +} + +impl BamlType for T { + fn baml_output_format() -> &'static OutputFormatContent { + &T::baml_schema().output_format + } fn baml_internal_name() -> &'static str { - ::baml_internal_name() + runtime::baml_internal_name::() } fn baml_type_ir() -> TypeIR { - ::baml_type_ir() + runtime::baml_type_ir::() } fn try_from_baml_value(value: BamlValue) -> Result { - ::try_from_baml_value(value, Vec::new()) + runtime::try_from_baml_value(value) } -} -impl BamlType for T { - fn baml_output_format() -> &'static OutputFormatContent { - ::baml_output_format() + fn to_baml_value(&self) -> BamlValue { + runtime::to_baml_value_lossy(self) } } diff --git a/crates/bamltype/src/runtime.rs b/crates/bamltype/src/runtime.rs index 14d306b3..a91d42b4 100644 --- a/crates/bamltype/src/runtime.rs +++ b/crates/bamltype/src/runtime.rs @@ -1,10 +1,8 @@ -//! Facet runtime traits and conversion helpers. +//! Facet runtime helpers and conversion glue. use baml_types::{BamlMap, BamlValue, TypeIR, type_meta}; use facet::Facet; -use internal_baml_jinja::types::OutputFormatContent; -use crate::BamlSchema; use crate::convert; use crate::schema_builder::build_type_ir_from_shape; @@ -75,77 +73,38 @@ impl From for BamlConvertError { } } -/// Internal type metadata. -pub trait BamlTypeInternal { - fn baml_internal_name() -> &'static str; - fn baml_type_ir() -> TypeIR; -} - -/// Convert from BamlValue to Rust. -pub trait BamlValueConvert: Sized { - fn try_from_baml_value(value: BamlValue, path: Vec) -> Result; -} - -/// Convert from Rust to BamlValue. -pub trait ToBamlValue { - fn to_baml_value(&self) -> BamlValue; -} - -/// Runtime trait used by schema rendering and parsing. -/// -/// Named `BamlTypeTrait` to avoid collision with the `#[BamlType]` attribute macro. -pub trait BamlTypeTrait: BamlTypeInternal + BamlValueConvert + Sized + 'static { - fn baml_output_format() -> &'static OutputFormatContent; - - fn baml_internal_name() -> &'static str { - ::baml_internal_name() - } - - fn baml_type_ir() -> TypeIR { - ::baml_type_ir() - } -} - -impl> BamlTypeInternal for T { - fn baml_internal_name() -> &'static str { - for attr in T::SHAPE.attributes { - if attr.ns != Some("bamltype") || attr.key != "internal_name" { - continue; - } - - if let Some(name) = attr.get_as::<&'static str>() { - return name; - } +/// Compute the BAML internal name for a type. +pub fn baml_internal_name>() -> &'static str { + for attr in T::SHAPE.attributes { + if attr.ns != Some("bamltype") || attr.key != "internal_name" { + continue; } - if let Some(name) = T::SHAPE.get_builtin_attr_value::<&'static str>("internal_name") { - name - } else { - std::any::type_name::() + if let Some(name) = attr.get_as::<&'static str>() { + return name; } } - fn baml_type_ir() -> TypeIR { - build_type_ir_from_shape(T::SHAPE) + if let Some(name) = T::SHAPE.get_builtin_attr_value::<&'static str>("internal_name") { + name + } else { + std::any::type_name::() } } -impl> BamlValueConvert for T { - fn try_from_baml_value(value: BamlValue, _path: Vec) -> Result { - convert::from_baml_value(value).map_err(BamlConvertError::from) - } +/// Compute TypeIR for a type from facet shape data. +pub fn baml_type_ir>() -> TypeIR { + build_type_ir_from_shape(T::SHAPE) } -impl> ToBamlValue for T { - fn to_baml_value(&self) -> BamlValue { - convert::to_baml_value(self).unwrap_or(BamlValue::Null) - } +/// Convert a BamlValue into a concrete Rust type with stable BamlConvertError shape. +pub fn try_from_baml_value>(value: BamlValue) -> Result { + convert::from_baml_value(value).map_err(BamlConvertError::from) } -impl BamlTypeTrait for T { - fn baml_output_format() -> &'static OutputFormatContent { - &T::baml_schema().output_format - } +/// Convert a Rust value into BamlValue, preserving legacy null-fallback behavior. +pub fn to_baml_value_lossy>(value: &T) -> BamlValue { + convert::to_baml_value(value).unwrap_or(BamlValue::Null) } /// Default streaming behavior helper. diff --git a/crates/bamltype/src/schema_builder.rs b/crates/bamltype/src/schema_builder.rs index c8624b7f..cf154db9 100644 --- a/crates/bamltype/src/schema_builder.rs +++ b/crates/bamltype/src/schema_builder.rs @@ -9,6 +9,7 @@ use facet::{Attr, ConstTypeId, Def, Field, Shape, Type, UserType}; use internal_baml_jinja::types::{Class, Enum, Name, OutputFormatContent}; use crate::SchemaBundle; +use crate::adapters::FieldCodecRegisterContext; use crate::facet_ext; use crate::schema_registry::SchemaRegistry; @@ -25,8 +26,7 @@ pub fn build_schema_bundle(shape: &'static Shape) -> SchemaBundle { /// Build a TypeIR from a facet Shape (without building full schema). /// -/// Used by runtime trait implementations to provide `BamlTypeInternal::baml_type_ir()` -/// for any `Facet` type. +/// Used by runtime helpers to provide `baml_type_ir::()` for any `Facet` type. pub fn build_type_ir_from_shape(shape: &'static Shape) -> TypeIR { let mut builder = SchemaBuilder::new(); builder.build_type_ir(shape) @@ -388,7 +388,14 @@ impl SchemaBuilder { variant_rendered: Option<&str>, ) -> TypeIR { if let Some(with) = facet_ext::with_adapter_fns(field.attributes) { - (with.register)(&mut self.registry); + (with.register)(FieldCodecRegisterContext { + registry: &mut self.registry, + owner_internal_name: Some(owner_internal_name), + field_name: Some(field.name), + rendered_field_name: Some(field.effective_name()), + variant_name, + rendered_variant_name: variant_rendered, + }); return (with.type_ir)(); } diff --git a/crates/bamltype/tests/contract_frozen_oracle.rs b/crates/bamltype/tests/contract_frozen_oracle.rs index e590ab2c..eade4c8f 100644 --- a/crates/bamltype/tests/contract_frozen_oracle.rs +++ b/crates/bamltype/tests/contract_frozen_oracle.rs @@ -227,8 +227,9 @@ impl facet_runtime::adapters::FieldCodec for FacetI64ObjectAdapter { TypeIR::class("AdapterI64Wrapper") } - fn register(reg: &mut facet_runtime::adapters::AdapterSchemaRegistry) { + fn register(ctx: facet_runtime::adapters::FieldCodecRegisterContext<'_>) { use facet_runtime::internal_baml_jinja::types::{Class, Name}; + let reg = ctx.registry; if !reg.mark_type("AdapterI64Wrapper") { return; @@ -1037,15 +1038,10 @@ fn contract_with_adapter_schema_and_parse_fixture() { fn assert_default_adapter_roundtrip(value: T) where - T: Clone - + std::fmt::Debug - + PartialEq - + facet_runtime::ToBamlValue - + facet_runtime::BamlValueConvert, + T: Clone + std::fmt::Debug + PartialEq + facet_runtime::facet::Facet<'static>, { - let baml = facet_runtime::ToBamlValue::to_baml_value(&value); - let back = ::try_from_baml_value(baml, Vec::new()) - .expect("roundtrip"); + let baml = facet_runtime::to_baml_value_lossy(&value); + let back = facet_runtime::try_from_baml_value::(baml).expect("roundtrip"); assert_eq!(back, value); } @@ -1101,11 +1097,8 @@ fn contract_default_adapter_error_shape_fixture() { .collect(), ); - let err = ::try_from_baml_value( - invalid, - Vec::new(), - ) - .expect_err("should fail"); + let err = + facet_runtime::try_from_baml_value::(invalid).expect_err("should fail"); let snapshot = serde_json::to_string_pretty(&json!({ "expected": err.expected, @@ -1127,11 +1120,8 @@ fn contract_default_adapter_error_shape_fixture() { #[test] fn contract_direct_integer_string_conversion_error_fixture() { - let err = ::try_from_baml_value( - BamlValue::String("123".into()), - Vec::new(), - ) - .expect_err("should reject"); + let err = facet_runtime::try_from_baml_value::(BamlValue::String("123".into())) + .expect_err("should reject"); let snapshot = serde_json::to_string_pretty(&json!({ "expected": err.expected, @@ -1161,11 +1151,8 @@ fn contract_direct_map_pairs_conversion_error_fixture() { ); let raw = BamlValue::List(vec![pair_entry]); - let err = as facet_runtime::BamlValueConvert>::try_from_baml_value( - raw, - Vec::new(), - ) - .expect_err("should reject"); + let err = + facet_runtime::try_from_baml_value::>(raw).expect_err("should reject"); let snapshot = serde_json::to_string_pretty(&json!({ "expected": err.expected, diff --git a/crates/bamltype/tests/integration.rs b/crates/bamltype/tests/integration.rs index b9cccb35..190f3e63 100644 --- a/crates/bamltype/tests/integration.rs +++ b/crates/bamltype/tests/integration.rs @@ -4,9 +4,9 @@ use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use baml_types::{BamlValue, ConstraintLevel, LiteralValue, StreamingMode, TypeIR}; -use bamltype::adapters::{AdapterSchemaRegistry, FieldCodec}; +use bamltype::adapters::{FieldCodec, FieldCodecRegisterContext}; use bamltype::{ - BamlSchema, BamlTypeInternal, from_baml_value, from_baml_value_with_flags, parse, + BamlSchema, BamlType, from_baml_value, from_baml_value_with_flags, parse, render_schema_default, to_baml_value, }; use indexmap::IndexMap; @@ -333,7 +333,7 @@ fn test_round_trip_box() { let baml = to_baml_value(&boxed).unwrap(); match &baml { BamlValue::Class(name, fields) => { - assert_eq!(name, ::baml_internal_name()); + assert_eq!(name, ::baml_internal_name()); assert_eq!(fields.get("name"), Some(&BamlValue::String("Boxed".into()))); assert_eq!(fields.get("age"), Some(&BamlValue::Int(33))); assert_eq!(fields.get("active"), Some(&BamlValue::Bool(false))); @@ -531,7 +531,7 @@ fn test_to_baml_enum() { assert_eq!( baml, BamlValue::Enum( - ::baml_internal_name().into(), + ::baml_internal_name().into(), "Green".into() ) ); @@ -587,7 +587,7 @@ fn test_empty_struct() { let baml = to_baml_value(&val).unwrap(); match baml { BamlValue::Class(name, fields) => { - assert_eq!(name, ::baml_internal_name()); + assert_eq!(name, ::baml_internal_name()); assert!(fields.is_empty()); } other => panic!("Expected Class, got {:?}", other), @@ -681,7 +681,7 @@ fn test_baml_enum_alias_round_trip() { assert_eq!( as_baml, BamlValue::Enum( - ::baml_internal_name().into(), + ::baml_internal_name().into(), "go".into() ) ); @@ -835,8 +835,9 @@ impl FieldCodec for U64ObjectAdapter { TypeIR::class("AdapterU64Wrapper") } - fn register(reg: &mut AdapterSchemaRegistry) { + fn register(ctx: FieldCodecRegisterContext<'_>) { use bamltype::internal_baml_jinja::types::{Class, Name}; + let reg = ctx.registry; if !reg.mark_type("AdapterU64Wrapper") { return; @@ -934,7 +935,7 @@ fn data_enum_tagged_parses() { #[test] fn as_union_unit_enum_type_ir_is_literal_union() { - let type_ir = ::baml_type_ir(); + let type_ir = ::baml_type_ir(); let TypeIR::Union(union, _) = type_ir else { panic!("expected union type IR"); }; @@ -1058,7 +1059,7 @@ fn map_key_repr_pairs_parses() { fn map_key_repr_pairs_registers_entry_class() { let entry_name = format!( "{}::values__Entry", - ::baml_internal_name() + ::baml_internal_name() ); let of = &MapKeysPairsParity::baml_schema().output_format; let class = of @@ -1074,7 +1075,7 @@ fn recursion_is_detected() { let of = &RecursiveNodeParity::baml_schema().output_format; assert!( of.recursive_classes - .contains(::baml_internal_name()) + .contains(::baml_internal_name()) ); } @@ -1089,7 +1090,7 @@ fn rename_all_applies() { #[test] fn field_constraints_are_registered() { let of = &CheckedValueParity::baml_schema().output_format; - let internal_name = ::baml_internal_name().to_string(); + let internal_name = ::baml_internal_name().to_string(); let class = of .classes .get(&(internal_name, StreamingMode::NonStreaming)) @@ -1110,7 +1111,7 @@ fn field_constraints_are_registered() { #[test] fn field_assert_constraints_are_registered() { let of = &AssertedValueParity::baml_schema().output_format; - let internal_name = ::baml_internal_name().to_string(); + let internal_name = ::baml_internal_name().to_string(); let class = of .classes .get(&(internal_name, StreamingMode::NonStreaming)) @@ -1130,8 +1131,8 @@ fn field_assert_constraints_are_registered() { #[test] fn internal_names_are_unique() { - let a_name = ::baml_internal_name(); - let b_name = ::baml_internal_name(); + let a_name = ::baml_internal_name(); + let b_name = ::baml_internal_name(); assert_ne!(a_name, b_name); let a_class = parity_collision_a::User::baml_schema() @@ -1165,7 +1166,7 @@ fn with_adapter_schema_and_registration_are_used() { .any(|(name, _, _, _)| name.real_name() == "value") ); - let owner_name = ::baml_internal_name().to_string(); + let owner_name = ::baml_internal_name().to_string(); let owner = of .classes .get(&(owner_name, StreamingMode::NonStreaming)) diff --git a/crates/bamltype/tests/property_parity.rs b/crates/bamltype/tests/property_parity.rs index 8f97214b..c08769a6 100644 --- a/crates/bamltype/tests/property_parity.rs +++ b/crates/bamltype/tests/property_parity.rs @@ -44,8 +44,9 @@ impl facet_runtime::adapters::FieldCodec for PropertyI64Adapter { TypeIR::class("PropertyAdapterI64Wrapper") } - fn register(reg: &mut facet_runtime::adapters::AdapterSchemaRegistry) { + fn register(ctx: facet_runtime::adapters::FieldCodecRegisterContext<'_>) { use facet_runtime::internal_baml_jinja::types::{Class, Name}; + let reg = ctx.registry; if !reg.mark_type("PropertyAdapterI64Wrapper") { return; diff --git a/crates/dspy-rs/src/adapter/chat.rs b/crates/dspy-rs/src/adapter/chat.rs index 1b3d1683..79770a7c 100644 --- a/crates/dspy-rs/src/adapter/chat.rs +++ b/crates/dspy-rs/src/adapter/chat.rs @@ -1,4 +1,8 @@ use anyhow::Result; +use bamltype::jsonish; +use bamltype::jsonish::BamlValueWithFlags; +use bamltype::jsonish::deserializer::coercer::run_user_checks; +use bamltype::jsonish::deserializer::deserialize_flags::DeserializerConditions; use indexmap::IndexMap; use regex::Regex; use rig::tool::ToolDyn; @@ -8,16 +12,12 @@ use std::sync::{Arc, LazyLock}; use tracing::{Instrument, debug, trace}; use super::Adapter; -use crate::bamltype::jsonish; -use crate::bamltype::jsonish::BamlValueWithFlags; -use crate::bamltype::jsonish::deserializer::coercer::run_user_checks; -use crate::bamltype::jsonish::deserializer::deserialize_flags::DeserializerConditions; use crate::serde_utils::get_iter_from_value; use crate::utils::cache::CacheEntry; use crate::{ - BamlTypeTrait, BamlValue, BamlValueConvert, Cache, Chat, ConstraintLevel, ConstraintResult, - Example, FieldMeta, Flag, JsonishError, LM, Message, MetaSignature, OutputFormatContent, - ParseError, Prediction, RenderOptions, Signature, ToBamlValue, TypeIR, + BamlType, BamlValue, Cache, Chat, ConstraintLevel, ConstraintResult, Example, FieldMeta, Flag, + JsonishError, LM, Message, MetaSignature, OutputFormatContent, ParseError, Prediction, + RenderOptions, Signature, TypeIR, }; #[derive(Default, Clone)] @@ -452,7 +452,7 @@ impl ChatAdapter { } fn format_field_descriptions_typed(&self) -> String { - let input_format = ::baml_output_format(); + let input_format = ::baml_output_format(); let output_format = S::output_format_content(); let mut lines = Vec::new(); @@ -518,13 +518,13 @@ impl ChatAdapter { pub fn format_user_message_typed(&self, input: &S::Input) -> String where - S::Input: ToBamlValue, + S::Input: BamlType, { let baml_value = input.to_baml_value(); let Some(fields) = baml_value_fields(&baml_value) else { return String::new(); }; - let input_output_format = ::baml_output_format(); + let input_output_format = ::baml_output_format(); let mut result = String::new(); for field_spec in S::input_fields() { @@ -544,7 +544,7 @@ impl ChatAdapter { pub fn format_assistant_message_typed(&self, output: &S::Output) -> String where - S::Output: ToBamlValue, + S::Output: BamlType, { let baml_value = output.to_baml_value(); let Some(fields) = baml_value_fields(&baml_value) else { @@ -569,8 +569,8 @@ impl ChatAdapter { pub fn format_demo_typed(&self, demo: S) -> (String, String) where - S::Input: ToBamlValue, - S::Output: ToBamlValue, + S::Input: BamlType, + S::Output: BamlType, { let (input, output) = demo.into_parts(); let user_msg = self.format_user_message_typed::(&input); @@ -598,7 +598,7 @@ impl ChatAdapter { let mut metas = IndexMap::new(); let mut errors = Vec::new(); - let mut output_map = crate::bamltype::baml_types::BamlMap::new(); + let mut output_map = bamltype::baml_types::BamlMap::new(); let mut checks_total = 0usize; let mut checks_failed = 0usize; let mut asserts_failed = 0usize; @@ -729,20 +729,17 @@ impl ChatAdapter { None } else { Some(BamlValue::Class( - ::baml_internal_name().to_string(), + ::baml_internal_name().to_string(), output_map, )) }; return Err(ParseError::Multiple { errors, partial }); } - let typed_output = ::try_from_baml_value( - BamlValue::Class( - ::baml_internal_name().to_string(), - output_map, - ), - Vec::new(), - ) + let typed_output = ::try_from_baml_value(BamlValue::Class( + ::baml_internal_name().to_string(), + output_map, + )) .map_err(|err| ParseError::ExtractionFailed { field: "".to_string(), raw_response: content.to_string(), @@ -863,7 +860,7 @@ fn parse_sections(content: &str) -> IndexMap { fn baml_value_fields( value: &BamlValue, -) -> Option<&crate::bamltype::baml_types::BamlMap> { +) -> Option<&bamltype::baml_types::BamlMap> { match value { BamlValue::Class(_, fields) => Some(fields), BamlValue::Map(fields) => Some(fields), @@ -894,7 +891,7 @@ fn format_baml_value_for_prompt_typed( } }; - crate::bamltype::internal_baml_jinja::format_baml_value(value, output_format, format) + bamltype::internal_baml_jinja::format_baml_value(value, output_format, format) .unwrap_or_else(|_| "".to_string()) } diff --git a/crates/dspy-rs/src/core/signature.rs b/crates/dspy-rs/src/core/signature.rs index 952a5464..b6e37c06 100644 --- a/crates/dspy-rs/src/core/signature.rs +++ b/crates/dspy-rs/src/core/signature.rs @@ -1,4 +1,4 @@ -use crate::{BamlTypeTrait, Example, OutputFormatContent, TypeIR}; +use crate::{BamlType, Example, OutputFormatContent, TypeIR}; use anyhow::Result; use serde_json::Value; @@ -37,8 +37,8 @@ pub trait MetaSignature: Send + Sync { } pub trait Signature: Send + Sync + 'static { - type Input: BamlTypeTrait + Send + Sync; - type Output: BamlTypeTrait + Send + Sync; + type Input: BamlType + Send + Sync; + type Output: BamlType + Send + Sync; fn instruction() -> &'static str; fn input_fields() -> &'static [FieldSpec]; diff --git a/crates/dspy-rs/src/lib.rs b/crates/dspy-rs/src/lib.rs index 0d59f5f8..24894c62 100644 --- a/crates/dspy-rs/src/lib.rs +++ b/crates/dspy-rs/src/lib.rs @@ -10,31 +10,32 @@ pub mod trace; pub mod utils; pub use adapter::chat::*; -pub use anyhow; pub use core::*; pub use data::*; pub use evaluate::*; -pub use indexmap; pub use optimizer::*; pub use predictors::*; -pub use schemars; -pub use serde; -pub use serde_json; pub use utils::*; -pub use bamltype; +pub use bamltype::BamlConvertError; pub use bamltype::BamlType; // attribute macro pub use bamltype::baml_types::{ BamlValue, Constraint, ConstraintLevel, ResponseCheck, StreamingMode, TypeIR, }; -pub use bamltype::facet; pub use bamltype::internal_baml_jinja::types::{OutputFormatContent, RenderOptions}; pub use bamltype::jsonish::deserializer::deserialize_flags::Flag; -pub use bamltype::{ - BamlConvertError, BamlTypeInternal, BamlTypeTrait, BamlValueConvert, ToBamlValue, -}; pub use dsrs_macros::*; +#[doc(hidden)] +pub mod __macro_support { + pub use anyhow; + pub use bamltype; + pub use indexmap; + pub use schemars; + pub use serde; + pub use serde_json; +} + #[deprecated( since = "0.2.0", note = "Use typed input structs instead, e.g., QAInput { question: ... }" @@ -118,7 +119,10 @@ macro_rules! prediction { let mut fields = HashMap::new(); $( - fields.insert($key.to_string(), $crate::serde_json::to_value($value).unwrap()); + fields.insert( + $key.to_string(), + $crate::__macro_support::serde_json::to_value($value).unwrap() + ); )* Prediction::new(fields, LmUsage::default()) @@ -144,15 +148,15 @@ macro_rules! field { // Pattern for field definitions with descriptions { $($field_type:ident[$desc:literal] => $field_name:ident : $field_ty:ty),* $(,)? } => {{ - use $crate::serde_json::json; + use $crate::__macro_support::serde_json::json; - let mut result = $crate::serde_json::Map::new(); + let mut result = $crate::__macro_support::serde_json::Map::new(); $( let type_str = stringify!($field_ty); let schema = { - let schema = $crate::schemars::schema_for!($field_ty); - let schema_json = $crate::serde_json::to_value(schema).unwrap(); + let schema = $crate::__macro_support::schemars::schema_for!($field_ty); + let schema_json = $crate::__macro_support::serde_json::to_value(schema).unwrap(); // Extract just the properties if it's an object schema if let Some(obj) = schema_json.as_object() { if obj.contains_key("properties") { @@ -175,20 +179,20 @@ macro_rules! field { ); )* - $crate::serde_json::Value::Object(result) + $crate::__macro_support::serde_json::Value::Object(result) }}; // Pattern for field definitions without descriptions { $($field_type:ident => $field_name:ident : $field_ty:ty),* $(,)? } => {{ - use $crate::serde_json::json; + use $crate::__macro_support::serde_json::json; - let mut result = $crate::serde_json::Map::new(); + let mut result = $crate::__macro_support::serde_json::Map::new(); $( let type_str = stringify!($field_ty); let schema = { - let schema = $crate::schemars::schema_for!($field_ty); - let schema_json = $crate::serde_json::to_value(schema).unwrap(); + let schema = $crate::__macro_support::schemars::schema_for!($field_ty); + let schema_json = $crate::__macro_support::serde_json::to_value(schema).unwrap(); // Extract just the properties if it's an object schema if let Some(obj) = schema_json.as_object() { if obj.contains_key("properties") { @@ -211,7 +215,7 @@ macro_rules! field { ); )* - $crate::serde_json::Value::Object(result) + $crate::__macro_support::serde_json::Value::Object(result) }}; } diff --git a/crates/dspy-rs/src/predictors/predict.rs b/crates/dspy-rs/src/predictors/predict.rs index cb657909..e8810cbf 100644 --- a/crates/dspy-rs/src/predictors/predict.rs +++ b/crates/dspy-rs/src/predictors/predict.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use bamltype::baml_types::BamlMap; use indexmap::IndexMap; use rig::tool::ToolDyn; use serde_json::{Value, json}; @@ -8,11 +9,10 @@ use std::sync::Arc; use tracing::{debug, trace}; use crate::adapter::Adapter; -use crate::bamltype::baml_types::BamlMap; use crate::core::{FieldSpec, MetaSignature, Module, Optimizable, Signature}; use crate::{ - BamlValue, BamlValueConvert, CallResult, Chat, ChatAdapter, Example, GLOBAL_SETTINGS, LM, - LmError, LmUsage, PredictError, Prediction, ToBamlValue, + BamlType, BamlValue, CallResult, Chat, ChatAdapter, Example, GLOBAL_SETTINGS, LM, LmError, + LmUsage, PredictError, Prediction, }; pub struct Predict { @@ -39,8 +39,8 @@ impl Predict { pub async fn call(&self, input: S::Input) -> Result where S: Clone, - S::Input: ToBamlValue, - S::Output: ToBamlValue, + S::Input: BamlType, + S::Output: BamlType, { Ok(self.call_with_meta(input).await?.output) } @@ -60,8 +60,8 @@ impl Predict { pub async fn call_with_meta(&self, input: S::Input) -> Result, PredictError> where S: Clone, - S::Input: ToBamlValue, - S::Output: ToBamlValue, + S::Input: BamlType, + S::Output: BamlType, { let lm = { let guard = GLOBAL_SETTINGS.read().unwrap(); @@ -304,28 +304,28 @@ fn output_keys_for_signature(example: &Example) -> Vec { fn input_from_example(example: &Example) -> Result where - S::Input: BamlValueConvert, + S::Input: BamlType, { let keys = input_keys_for_signature::(example); let map = baml_map_from_example_keys(&example.data, &keys)?; let baml_value = BamlValue::Map(map); - S::Input::try_from_baml_value(baml_value, Vec::new()).map_err(|err| anyhow::anyhow!(err)) + S::Input::try_from_baml_value(baml_value).map_err(|err| anyhow::anyhow!(err)) } fn output_from_example(example: &Example) -> Result where - S::Output: BamlValueConvert, + S::Output: BamlType, { let keys = output_keys_for_signature::(example); let map = baml_map_from_example_keys(&example.data, &keys)?; let baml_value = BamlValue::Map(map); - S::Output::try_from_baml_value(baml_value, Vec::new()).map_err(|err| anyhow::anyhow!(err)) + S::Output::try_from_baml_value(baml_value).map_err(|err| anyhow::anyhow!(err)) } fn signature_from_example(example: Example) -> Result where - S::Input: BamlValueConvert, - S::Output: BamlValueConvert, + S::Input: BamlType, + S::Output: BamlType, { let input = input_from_example::(&example)?; let output = output_from_example::(&example)?; @@ -334,8 +334,8 @@ where fn example_from_signature(signature: S) -> Result where - S::Input: ToBamlValue, - S::Output: ToBamlValue, + S::Input: BamlType, + S::Output: BamlType, { let (input, output) = signature.into_parts(); let input_value = serde_json::to_value(input.to_baml_value())?; @@ -366,7 +366,7 @@ fn prediction_from_output( node_id: Option, ) -> Result where - S::Output: ToBamlValue, + S::Output: BamlType, { let output_value = serde_json::to_value(output.to_baml_value())?; let output_map = output_value @@ -384,9 +384,9 @@ where impl Module for Predict where - S: Signature + Clone + ToBamlValue, - S::Input: ToBamlValue + BamlValueConvert, - S::Output: ToBamlValue + BamlValueConvert, + S: Signature + Clone + BamlType, + S::Input: BamlType, + S::Output: BamlType, { #[tracing::instrument( name = "dsrs.module.forward", @@ -427,14 +427,13 @@ where &self, input: BamlValue, ) -> std::result::Result { - let typed_input = - S::Input::try_from_baml_value(input.clone(), Vec::new()).map_err(|err| { - debug!(error = %err, "untyped input conversion failed"); - PredictError::Conversion { - source: err.into(), - parsed: input, - } - })?; + let typed_input = S::Input::try_from_baml_value(input.clone()).map_err(|err| { + debug!(error = %err, "untyped input conversion failed"); + PredictError::Conversion { + source: err.into(), + parsed: input, + } + })?; let output = self.call(typed_input).await?; debug!("typed module forward_untyped complete"); Ok(output.to_baml_value()) @@ -444,8 +443,8 @@ where impl MetaSignature for Predict where S: Signature + Clone, - S::Input: BamlValueConvert + ToBamlValue, - S::Output: BamlValueConvert + ToBamlValue, + S::Input: BamlType, + S::Output: BamlType, { fn demos(&self) -> Vec { self.demos @@ -494,8 +493,8 @@ where impl Optimizable for Predict where S: Signature + Clone, - S::Input: BamlValueConvert + ToBamlValue, - S::Output: BamlValueConvert + ToBamlValue, + S::Input: BamlType, + S::Output: BamlType, { fn get_signature(&self) -> &dyn MetaSignature { self diff --git a/crates/dspy-rs/tests/test_bamltype_attr_contract.rs b/crates/dspy-rs/tests/test_bamltype_attr_contract.rs index 37046b52..1d661b3b 100644 --- a/crates/dspy-rs/tests/test_bamltype_attr_contract.rs +++ b/crates/dspy-rs/tests/test_bamltype_attr_contract.rs @@ -1,4 +1,4 @@ -use dspy_rs::{BamlType, BamlTypeTrait, RenderOptions}; +use dspy_rs::{BamlType, RenderOptions}; #[derive(Debug, Clone, PartialEq)] #[BamlType] @@ -11,14 +11,14 @@ struct DsrsUser { #[test] fn bamltype_attribute_macro_works_from_dspy_rs() { - let schema = ::baml_output_format() + let schema = ::baml_output_format() .render(RenderOptions::default()) .expect("render schema") .unwrap_or_default(); assert!(schema.contains("fullName")); let raw = r#"{ "fullName": "Ada", "age": 36 }"#; - let parsed = dspy_rs::bamltype::parse_llm_output::(raw, true).expect("parse"); + let parsed = bamltype::parse_llm_output::(raw, true).expect("parse"); assert_eq!(parsed.value.name, "Ada"); assert_eq!(parsed.value.age, 36); } diff --git a/crates/dspy-rs/tests/test_bamltype_docs_contract.rs b/crates/dspy-rs/tests/test_bamltype_docs_contract.rs index 8605cd33..f1216b73 100644 --- a/crates/dspy-rs/tests/test_bamltype_docs_contract.rs +++ b/crates/dspy-rs/tests/test_bamltype_docs_contract.rs @@ -1,5 +1,5 @@ -use dspy_rs::bamltype::HoistClasses; -use dspy_rs::{BamlType, BamlTypeTrait, ChatAdapter, RenderOptions, Signature}; +use bamltype::HoistClasses; +use dspy_rs::{BamlType, ChatAdapter, RenderOptions, Signature}; #[derive(Clone, Debug)] #[BamlType] @@ -134,7 +134,7 @@ fn name_changes_type_label_in_prompt_and_hoisted_render() { "expected renamed type label in prompt:\n{block}" ); - let rendered = ::baml_output_format() + let rendered = ::baml_output_format() .render(RenderOptions::hoist_classes(HoistClasses::All)) .expect("render") .unwrap_or_default(); @@ -146,11 +146,9 @@ fn name_changes_type_label_in_prompt_and_hoisted_render() { #[test] fn parse_behavior_matches_skip_and_default_claims() { - let parsed = dspy_rs::bamltype::parse_llm_output::( - r#"{ "content": "hello" }"#, - true, - ) - .expect("parse"); + let parsed = + bamltype::parse_llm_output::(r#"{ "content": "hello" }"#, true) + .expect("parse"); assert_eq!(parsed.value.content, "hello"); assert_eq!(parsed.value.internal_id, 0); diff --git a/crates/dspy-rs/tests/test_input_format.rs b/crates/dspy-rs/tests/test_input_format.rs index 13bd59ff..0f9a9558 100644 --- a/crates/dspy-rs/tests/test_input_format.rs +++ b/crates/dspy-rs/tests/test_input_format.rs @@ -1,4 +1,4 @@ -use dspy_rs::{BamlType, BamlTypeTrait, BamlValue, ChatAdapter, Signature, ToBamlValue}; +use dspy_rs::{BamlType, BamlValue, ChatAdapter, Signature}; #[derive(Clone, Debug)] #[BamlType] @@ -136,13 +136,10 @@ fn typed_input_format_toon_matches_formatter() { let baml_value = input.to_baml_value(); let context_baml = extract_baml_field(&baml_value, "context"); - let output_format = ::baml_output_format(); - let expected = dspy_rs::bamltype::internal_baml_jinja::format_baml_value( - context_baml, - output_format, - "toon", - ) - .expect("formatting should succeed"); + let output_format = ::baml_output_format(); + let expected = + bamltype::internal_baml_jinja::format_baml_value(context_baml, output_format, "toon") + .expect("formatting should succeed"); assert_eq!(context_value, expected); } diff --git a/crates/dsrs-macros/src/lib.rs b/crates/dsrs-macros/src/lib.rs index 09304b8f..510860e3 100644 --- a/crates/dsrs-macros/src/lib.rs +++ b/crates/dsrs-macros/src/lib.rs @@ -476,7 +476,7 @@ fn generate_field_specs( if field.constraints.is_empty() { type_ir_fns.push(quote! { fn #type_ir_fn_name() -> #runtime::TypeIR { - <#ty as #runtime::BamlTypeInternal>::baml_type_ir() + #runtime::__macro_support::bamltype::baml_type_ir::<#ty>() } }); } else { @@ -500,7 +500,7 @@ fn generate_field_specs( type_ir_fns.push(quote! { fn #type_ir_fn_name() -> #runtime::TypeIR { - let mut base = <#ty as #runtime::BamlTypeInternal>::baml_type_ir(); + let mut base = #runtime::__macro_support::bamltype::baml_type_ir::<#ty>(); base.meta_mut().constraints.extend(vec![#(#constraint_tokens),*]); base } @@ -585,47 +585,37 @@ fn generate_baml_delegation( to_value_inserts.push(quote! { fields.insert( #field_name.to_string(), - #runtime::ToBamlValue::to_baml_value(&self.#ident), + #runtime::__macro_support::bamltype::to_baml_value(&self.#ident).unwrap_or(#runtime::BamlValue::Null), ); }); } quote! { - impl #runtime::BamlTypeInternal for #name { + impl #runtime::BamlType for #name { + fn baml_output_format() -> &'static #runtime::OutputFormatContent { + <#all_name as #runtime::BamlType>::baml_output_format() + } + fn baml_internal_name() -> &'static str { - <#all_name as #runtime::BamlTypeInternal>::baml_internal_name() + <#all_name as #runtime::BamlType>::baml_internal_name() } fn baml_type_ir() -> #runtime::TypeIR { - <#all_name as #runtime::BamlTypeInternal>::baml_type_ir() + <#all_name as #runtime::BamlType>::baml_type_ir() } - } - impl #runtime::BamlValueConvert for #name { - fn try_from_baml_value( - value: #runtime::BamlValue, - path: Vec, - ) -> Result { - let all = <#all_name as #runtime::BamlValueConvert> - ::try_from_baml_value(value, path)?; + fn try_from_baml_value(value: #runtime::BamlValue) -> Result { + let all = <#all_name as #runtime::BamlType>::try_from_baml_value(value)?; Ok(Self { #(#field_names: all.#field_names),* }) } - } - - impl #runtime::BamlTypeTrait for #name { - fn baml_output_format() -> &'static #runtime::OutputFormatContent { - <#all_name as #runtime::BamlTypeTrait>::baml_output_format() - } - } - impl #runtime::ToBamlValue for #name { fn to_baml_value(&self) -> #runtime::BamlValue { - let mut fields = #runtime::bamltype::baml_types::BamlMap::new(); + let mut fields = #runtime::__macro_support::bamltype::baml_types::BamlMap::new(); #(#to_value_inserts)* - #runtime::bamltype::baml_types::BamlValue::Class( - ::baml_internal_name() + #runtime::__macro_support::bamltype::baml_types::BamlValue::Class( + ::baml_internal_name() .to_string(), fields, ) @@ -676,7 +666,7 @@ fn generate_signature_impl( } fn output_format_content() -> &'static #runtime::OutputFormatContent { - <#output_name as #runtime::BamlTypeTrait>::baml_output_format() + <#output_name as #runtime::BamlType>::baml_output_format() } fn from_parts(input: Self::Input, output: Self::Output) -> Self { @@ -818,8 +808,8 @@ pub fn LegacySignature(attr: TokenStream, item: TokenStream) -> TokenStream { let field_name_str = field_name.to_string(); schema_updates.push(quote! { { - let schema = #runtime::schemars::schema_for!(#field_type); - let schema_json = #runtime::serde_json::to_value(schema).unwrap(); + let schema = #runtime::__macro_support::schemars::schema_for!(#field_type); + let schema_json = #runtime::__macro_support::serde_json::to_value(schema).unwrap(); // Extract just the properties if it's an object schema if let Some(obj) = schema_json.as_object() { if obj.contains_key("properties") { @@ -840,8 +830,8 @@ pub fn LegacySignature(attr: TokenStream, item: TokenStream) -> TokenStream { let field_name_str = field_name.to_string(); schema_updates.push(quote! { { - let schema = #runtime::schemars::schema_for!(#field_type); - let schema_json = #runtime::serde_json::to_value(schema).unwrap(); + let schema = #runtime::__macro_support::schemars::schema_for!(#field_type); + let schema_json = #runtime::__macro_support::serde_json::to_value(schema).unwrap(); // Extract just the properties if it's an object schema if let Some(obj) = schema_json.as_object() { if obj.contains_key("properties") { @@ -883,18 +873,18 @@ pub fn LegacySignature(attr: TokenStream, item: TokenStream) -> TokenStream { let output_schema_str = serde_json::to_string(&output_schema).unwrap(); let generated = quote! { - #[derive(Default, Debug, Clone, #runtime::serde::Serialize, #runtime::serde::Deserialize)] + #[derive(Default, Debug, Clone, #runtime::__macro_support::serde::Serialize, #runtime::__macro_support::serde::Deserialize)] struct #struct_name { instruction: String, - input_fields: #runtime::serde_json::Value, - output_fields: #runtime::serde_json::Value, + input_fields: #runtime::__macro_support::serde_json::Value, + output_fields: #runtime::__macro_support::serde_json::Value, demos: Vec<#runtime::Example>, } impl #struct_name { pub fn new() -> Self { - let mut input_fields: #runtime::serde_json::Value = #runtime::serde_json::from_str(#input_schema_str).unwrap(); - let mut output_fields: #runtime::serde_json::Value = #runtime::serde_json::from_str(#output_schema_str).unwrap(); + let mut input_fields: #runtime::__macro_support::serde_json::Value = #runtime::__macro_support::serde_json::from_str(#input_schema_str).unwrap(); + let mut output_fields: #runtime::__macro_support::serde_json::Value = #runtime::__macro_support::serde_json::from_str(#output_schema_str).unwrap(); // Update schemas for complex types #(#schema_updates)* @@ -921,7 +911,7 @@ pub fn LegacySignature(attr: TokenStream, item: TokenStream) -> TokenStream { self.demos.clone() } - fn set_demos(&mut self, demos: Vec<#runtime::Example>) -> #runtime::anyhow::Result<()> { + fn set_demos(&mut self, demos: Vec<#runtime::Example>) -> #runtime::__macro_support::anyhow::Result<()> { self.demos = demos; Ok(()) } @@ -930,20 +920,20 @@ pub fn LegacySignature(attr: TokenStream, item: TokenStream) -> TokenStream { self.instruction.clone() } - fn input_fields(&self) -> #runtime::serde_json::Value { + fn input_fields(&self) -> #runtime::__macro_support::serde_json::Value { self.input_fields.clone() } - fn output_fields(&self) -> #runtime::serde_json::Value { + fn output_fields(&self) -> #runtime::__macro_support::serde_json::Value { self.output_fields.clone() } - fn update_instruction(&mut self, instruction: String) -> #runtime::anyhow::Result<()> { + fn update_instruction(&mut self, instruction: String) -> #runtime::__macro_support::anyhow::Result<()> { self.instruction = instruction; Ok(()) } - fn append(&mut self, name: &str, field_value: #runtime::serde_json::Value) -> #runtime::anyhow::Result<()> { + fn append(&mut self, name: &str, field_value: #runtime::__macro_support::serde_json::Value) -> #runtime::__macro_support::anyhow::Result<()> { match field_value["__dsrs_field_type"].as_str() { Some("input") => { self.input_fields[name] = field_value; @@ -952,7 +942,7 @@ pub fn LegacySignature(attr: TokenStream, item: TokenStream) -> TokenStream { self.output_fields[name] = field_value; } _ => { - return Err(#runtime::anyhow::anyhow!("Invalid field type: {:?}", field_value["__dsrs_field_type"].as_str())); + return Err(#runtime::__macro_support::anyhow::anyhow!("Invalid field type: {:?}", field_value["__dsrs_field_type"].as_str())); } } Ok(()) diff --git a/crates/dsrs-macros/src/optim.rs b/crates/dsrs-macros/src/optim.rs index e59d8cd6..84583d29 100644 --- a/crates/dsrs-macros/src/optim.rs +++ b/crates/dsrs-macros/src/optim.rs @@ -40,14 +40,14 @@ pub fn optimizable_impl(input: TokenStream) -> TokenStream { impl #impl_generics #trait_path for #name #type_generics #where_clause { fn parameters( &mut self, - ) -> #runtime::indexmap::IndexMap<::std::string::String, &mut dyn #trait_path> { - let mut params: #runtime::indexmap::IndexMap<::std::string::String, &mut dyn #trait_path> = #runtime::indexmap::IndexMap::new(); + ) -> #runtime::__macro_support::indexmap::IndexMap<::std::string::String, &mut dyn #trait_path> { + let mut params: #runtime::__macro_support::indexmap::IndexMap<::std::string::String, &mut dyn #trait_path> = #runtime::__macro_support::indexmap::IndexMap::new(); #( { let __field_name = stringify!(#parameter_names).to_string(); // SAFETY: We only create disjoint mutable borrows to distinct struct fields let __field_ptr: *mut dyn #trait_path = &mut self.#parameter_names as *mut dyn #trait_path; - let __child_params: #runtime::indexmap::IndexMap<::std::string::String, &mut dyn #trait_path> = unsafe { (&mut *__field_ptr).parameters() }; + let __child_params: #runtime::__macro_support::indexmap::IndexMap<::std::string::String, &mut dyn #trait_path> = unsafe { (&mut *__field_ptr).parameters() }; if __child_params.is_empty() { // Leaf: insert the field itself unsafe { diff --git a/crates/dsrs-macros/tests/signature_derive.rs b/crates/dsrs-macros/tests/signature_derive.rs index c84f02f7..797305e3 100644 --- a/crates/dsrs-macros/tests/signature_derive.rs +++ b/crates/dsrs-macros/tests/signature_derive.rs @@ -68,7 +68,7 @@ fn test_from_parts_into_parts() { #[test] fn test_baml_type_impl() { - let _ = ::baml_output_format(); + let _ = ::baml_output_format(); } #[test] diff --git a/docs/docs/getting-started/introduction.mdx b/docs/docs/getting-started/introduction.mdx new file mode 100644 index 00000000..3df3c6e9 --- /dev/null +++ b/docs/docs/getting-started/introduction.mdx @@ -0,0 +1,24 @@ +--- +title: "Introduction" +description: "Explaining the paradigm" +icon: "book" +--- + +Okay so this is an opinionated way to think about how to interact with language models. + +And what's the actual explanation here? Right, what the fuck does this mean? It's basically saying that your unit of interaction with the model is fundamentally typed. It consists of three things: +1. Inputs +2. Outputs +3. Instructions + +At the very simplest level, let's say you have a conversation. You're talking to ChatGPT. In this case, ChatGPT's instruction would be "You are a helpful assistant, please assist the user." The inputs would be the conversation so far and the system prompt. The output would be your message and the next turn of the conversation. A full chatbot conversation is like recursive right and all that jazz. The outputs feed into the inputs whatever. But it basically what we're doing here is asking you to represent the way that you're thinking about interacting with the models as composed of these three fundamental abstractions: +1. Inputs +2. Outputs +3. Instructions + + +The special part about DSRS is that these inputs and these outputs can take the full power and flexibility of the Rust type system. But let's not get into that yet, right? So what we basically do is we say, "Okay, you define your input structs, you define your output structs," and you say, "This is the instructions that you want," right? It's all in docstrings, it lives in your code. + +And then what you do is you you describe exactly what you want instead of using words, you use types and docstrings. What happens is that all of this is compiled down into a nice universal prompt format and it's parsed really well. There's a robust parser arguably the best in the world and all of this is just machinery to say that the vast majority of prompts that you will ever want to write will be generic. The thing that makes your prompts prompts isn't the way that you word things, the thing that we are just forcing you to be explicit about your intent. Like what you really mean and we ask you to encode these into rich beautiful Rust types, like really take full advantage of the incredible Rust type system. + + diff --git a/docs/specs/modules/breadboard.md b/docs/specs/modules/breadboard.md new file mode 100644 index 00000000..2211eeaf --- /dev/null +++ b/docs/specs/modules/breadboard.md @@ -0,0 +1,493 @@ +# DSRs Module System — Breadboard + +> Shape F: Facet-native typed modules with dynamic graph escape hatch +> Parts: F1–F12 (see [shapes.md](./shapes.md)) +> Procedure: Designing from Shaped Parts (breadboarding skill) + +--- + +## Adaptation Notes + +This breadboard applies the standard methodology to a **Rust library**, not a web UI. + +| Concept | Web UI | Rust Library | +|---------|--------|--------------| +| **Place** | Screen / page / modal | Developer context — bounded API surface for a specific role | +| **UI Affordance (U)** | Button, input, display | Public API — derives, constructors, method calls, return types | +| **Code Affordance (N)** | Handler, service, subscription | Internal mechanism — macro expansion, schema derivation, Facet walking, caching | +| **Data Store (S)** | Component state, DB table | Runtime state — `OnceLock` caches, demo vectors, graph node maps | +| **Control** | click, type, scroll, render | `compile` (derive/attr — compiler gives immediate feedback), `write` (source code), `call` (method), `construct` (value), `access` (field/type) | + +**Blocking test (adapted):** For a library, the blocking test is **cognitive, not physical** — everything is `pub` in Rust. A Place is separate when reaching its affordances requires a fundamentally different mental model. P1 users never need to understand prompt formatting internals (P2), Facet reflection (P3), or graph topology (P4). The "block" is that using those affordances requires knowledge the developer doesn't have and doesn't need. + +**External dependencies (not breadboarded):** `BamlType` (jsonish coercion), `Facet` (reflection/shapes), LM provider (API calls), global LM configuration (`GLOBAL_SETTINGS` — existing infrastructure). These are foundations the system builds on, not interactive Places. + +--- + +## Places + +| # | Place | Developer Role | Description | Layer | +|---|-------|----------------|-------------|-------| +| **P1** | User Code | App developer | Define signatures, pick modules, call them, access results. The 90% path. | L1 | +| **P2** | Module Authoring | Library / advanced developer | Create new augmentations and modules. Generic signatures, adapter building blocks, `impl Module`. | L1 | +| **P3** | Optimization | Optimizer internals | Discover Predict leaves via Facet walker, mutate demos/instructions via DynPredictor, serialize parameter state. | L2 | +| **P4** | Dynamic Graph | Structural optimizer / interop | Construct ProgramGraph, create DynModules from strategy registry, validate edges, execute graph. Fully untyped. | L3 | + +**Architectural invariants:** +- **Dependency direction is acyclic:** P1 ← P2 ← P3 ← P4. Each layer sees the one below, never above. No cycles. +- **S1 (SignatureSchema cache) is the shared backbone:** Written once (immutable after init), read by all Places. Immutable shared state across Places is coupling in name only — it's a computed property of types. If this invariant were ever violated (mutable schema), the whole Place decomposition would collapse. +- **L1/L2 share a compilation unit.** `Predict` implements `DynPredictor` in the same crate (`dspy-rs`). This is intentional dependency inversion: L2 defines the interface (`DynPredictor`), L1 satisfies it. Predict carries `PredictAccessorFns` as a static Shape attribute — zero-cost at runtime (no vtable, no allocation, just static data). **Tradeoff:** zero-registration automatic discovery, paid for with a shared compilation unit. L1 cannot be compiled without L2 type definitions. The layer separation is enforced by API design (P1 users never import L2 types), not by the crate graph. +- **"Structure IS declaration" — with a known hole.** The walker discovers Predict leaves by reflecting on struct fields. Module authors don't annotate `#[parameter]` or implement traversal. BUT: the walker cannot traverse containers (`Vec`, `Option`, `HashMap`, `Box`). Predictors inside containers are invisible to the optimizer. **Mitigation:** N18 errors (not silently skips) when encountering a container whose inner type has `dsrs::parameter`. See S5 (deferred). +- **Module combinators must be Facet-transparent.** Any wrapper that composes modules (Map, AndThen, Pipe) must expose inner modules as struct fields visible to the F6 walker (N18), not behind trait objects. `Map` requires a manual Facet impl walking only `inner: M` (closures are opaque to Facet derive). `BestOfN` has `module: M` as a concrete typed field. If a combinator hides the inner module behind `Box`, the walker cannot find Predict leaves inside — optimization breaks silently. This is the same container limitation as above, applied to combinators. **Path namespace consequence:** Wrapping a module changes path prefixes — `predict` becomes `inner.predict`. Serialized optimizer state (U36) is tied to the module tree shape. Changing the tree (adding/removing a wrapper) invalidates saved state with a clear error, not silent misapplication. + +**Boundary notes:** +- **P1 → P2 boundary:** P1 users *consume* what P2 creates. The blocking test is cognitive: P2 affordances (`#[derive(Augmentation)]`, adapter building blocks, `impl Module`) require understanding prompt pipeline internals, wrapper type mechanics, and Facet composition — a fundamentally different mental model from P1's "pick a module, call it." P2 is a valid separate Place even though nothing physically prevents a P1 user from importing P2 APIs. **Ramp:** Module combinators (U51: `.map()`, `.and_then()`) let P1 users customize output without crossing into P2. The cliff from "use a library module" to "author your own module" has an intermediate step. +- **P1 → P3 boundary:** P1 users hand their module to an optimizer. The optimizer reaches INTO their module's Predict leaves via the F6 walker + F8 DynPredictor vtable. The user's typed module is never replaced — the optimizer mutates state within it. Walker takes `&mut module` (exclusive access during optimization). +- **P3 → P4 boundary:** P3 (parameter optimization) works within existing typed modules. P4 (structural optimization) changes the module *topology* — adding, removing, or replacing nodes in a graph. P4 subsumes P3's capabilities. +- **Layer 0** (Types: `BamlType + Facet`) underpins all Places but is not a Place — it has no interactive affordances. Types are the source of truth, compiled into the binary. + +**Resolved gaps:** +- ~~No LM configuration affordance~~ → **Global default with scoped override.** LM is globally scoped (existing `GLOBAL_SETTINGS` infrastructure). `dsrs::with_lm(eval_lm, || ...)` overrides per-call via scoped context. N8 checks scoped context first, falls back to global default. Global LM configuration is existing infrastructure, not breadboarded (see External dependencies). +- ~~No batching affordance~~ → **Standalone utility, not a trait method.** `dsrs::forward_all(&module, inputs, concurrency)` → `Vec>` (Vec-of-Results, not Result-of-Vec — individual failures don't abort batch). Module trait stays minimal (one method: `forward`). Rationale: a default `forward_batch` on Module forces P2 authors to reason about concurrency composition — BestOfN already runs N concurrent calls per invocation, so default batching would produce `batch_size × N` concurrent LM requests. Standalone utility keeps this concern at P1. See U48. +- ~~Error paths underspecified~~ → `PredictError` carries raw LM response + failed field + stage + coercion detail. Error `Display` includes full LM response for iterative debugging. No separate debug API needed for V1. See U49. +- ~~Container traversal silently fails~~ → N18 errors on containers with `dsrs::parameter` inner types. See architectural invariant above. +- ~~Strategy swap blast radius understated~~ → Updated U16 to note output type change. +- ~~N12/N13 status~~ → **Keep N13, collapse N12 into N8.** N12 (jsonish coerce) is part of the "text → BamlValue" pipeline inside N8. N13 (try_from_baml_value) is a distinct error boundary: "BamlValue → typed output." Two affordances, two error semantics (N8 failures = coercion/parsing, N13 failures = type mismatch). +- ~~Missing P1→P3 handoff~~ → Added U50 (`optimizer.compile(&mut module, trainset, metric)`). Exclusive `&mut` during optimization = no concurrent `forward()`. +- ~~P1→P2 cliff too sharp~~ → **Module combinators as P1 ramp.** Without combinators, a P1 user who wants to post-process output (e.g., derive a confidence score from reasoning) must jump to full `impl Module` — learning associated types, async plumbing, and the Module trait. With `.map()` / `.and_then()`, they write a closure. Added U51 (module combinators). This is the intermediate step between "use a library module" and "author your own module." + +**N-affordance principle:** Keep **orchestration boundaries** (N3, N8, N17, N18, N25/N26) and **error/decision boundaries** (N13, N22, N23, N24). Collapse pure pipes/transforms into their parent. Test: "can you change the implementation without changing any wiring?" If yes, it's guts, not an affordance. + +**Open (from late-stage team conversation):** +- ⚠️ **P1→P2 cliff / Module combinators:** Resolved — see U51 (`.map()`, `.and_then()`) and boundary note on P1→P2. **Remaining question:** Module combinators must be Facet-transparent for the F6 walker (N18) to see through them. `Map` needs a manual Facet impl exposing `inner: M` as a field (closures are opaque to Facet derive). This is an architectural invariant on all future combinators: they must expose inner modules as struct fields, not trait objects. +- ⚠️ **CallOutcome as V1 return type:** Systems-thinker and adversarial-user converged on: `CallOutcome` (carrying both `Result` and metadata like token_usage, latency) should be the V1 return type for N8, not deferred. Argument: N8's return type is a chokepoint — changing it later ripples through N13, U10, U37, and every `impl Module`. One breaking change now vs two later. Type refinement on existing wires (same topology, richer payload). Waiting on concrete ergonomics analysis: does wrapping `Result` in `CallOutcome` break `?` operator flow for P1? + +**Deferred (acknowledged, out of scope for V1):** +- ⚠️ **Observability / N8 return type chokepoint:** N8 currently has no wire for "what happened during the call" — only "what was the result." Token tracking, prompt logging, cost require N8 to emit metadata via either a richer return type (`CallOutcome` carrying both result and metadata) or a parallel tracing/spans channel. **Chokepoint risk:** N8's return type ripples through N13, U10, U37, and every `impl Module`. Changing it post-V1 is a breaking change. Consider whether `CallOutcome` should be the V1 return type to avoid a painful migration later. Waiting on concrete ergonomics analysis (does it break `?` operator for P1?). **Note:** CallOutcome is a *type refinement* on existing wires, not a topology change — same wires, richer payload. No new N-affordances or wiring needed. The breadboard structure is unchanged either way; this is a design-reference-level decision. +- ⚠️ **Operational policy (retries, timeouts, rate limits):** Per-call execution policy — combinators around `forward()`. P1 affordances that wire to U9. No new stores, no new coupling. Easy to add, no architectural impact. +- ⚠️ **Container traversal (Vec, Option, HashMap, Box):** Walker errors on containers with `dsrs::parameter` inner types (N18). Full traversal deferred — tracked in S5. + +--- + +## UI Affordances (Public API Surface) + +| # | Place | Module | Affordance | Control | Wires Out | Returns To | Part | +|---|-------|--------|------------|---------|-----------|------------|------| +| **U1** | P1 | `signature` | `#[derive(Signature)]` on struct | compile | → N1 | — | F1 | +| **U2** | P1 | `signature` | `#[input]` / `#[output]` field markers | compile | → N1 | — | F1 | +| **U3** | P1 | `signature` | Doc comment on signature struct | compile | → N1, → N2 | — | F1 | +| **U4** | P1 | `signature` | `QAInput` generated type | construct | → U9 | — | F1 | +| **U5** | P1 | `signature` | `QAOutput` / `S::Output` received type | access | → U11, → U12 | ← N13 | F1 | +| **U6** | P1 | `predict` | `Predict::::new()` | construct | → S2, → S3 | — | F5 | +| **U7** | P1 | `predict` | `Predict::::builder().demo(...).instruction(...).build()` | construct | → S2, → S3, → S4 | — | F5 | +| **U8** | P1 | `predict` | `Demo { input: ..., output: ... }` | construct | → U7 | — | F5 | +| **U9** | P1 | `module` | `module.forward(input).await` | call | → N3 | → U10 | F4 | +| **U10** | P1 | `module` | `Result` | access | → U5 (Ok) | ← N8 | F4 | +| **U11** | P1 | — | `result.answer` — direct field access | access | — | ← U5 | F1 | +| **U12** | P1 | — | `result.reasoning` — Deref to augmented field | access | — | ← U5 | F3 | +| **U13** | P1 | `library` | `ChainOfThought::::new()` | construct | → S2 (internal predict) | — | F11 | +| **U14** | P1 | `library` | `ReAct::::builder().tool("name", "desc", fn).build()` | construct | → S2, → S4 | — | F11 | +| **U16** | P1 | — | Strategy swap: change type annotation (e.g. `Predict` → `ChainOfThought`). **Note:** output type also changes (`QAOutput` → `WithReasoning`), breaking explicit type annotations and downstream function signatures. Compiler catches all breakage. | compile | — | — | F4 | +| **U48** | P1 | `module` | `dsrs::forward_all(&module, inputs, concurrency).await` — standalone utility. Returns `Vec>`. Individual failures don't abort batch. Module trait stays minimal (one method). | call | → N8 (×N) | → Vec\ | F4 | +| **U50** | P1 | `optimizer` | `optimizer.compile(&mut module, trainset, metric).await` — hands module to optimizer. Exclusive `&mut` = no concurrent forward() during optimization. This is the P1→P3 entry point. | call | → U30 (P3 entry) | → &mut module (optimized in place) | F6, F8 | +| **U51** | P1 | `module` | `module.map(\|output\| transform(output))` — output transformation combinator. Constructs `Map` wrapping the original module. Also `.and_then()` for fallible transforms. P1 ramp to avoid `impl Module` for simple post-processing (e.g., derive confidence from reasoning). Map/AndThen must have manual Facet impls exposing `inner` field for N18 walker traversal. | construct | — | → Module\ | F4 | +| **U49** | P1 | `module` | `PredictError` variants — `Provider { source }` (retry-worthy: network, timeout, rate limit), `Parse { raw_response, field, stage, detail }` (prompt-engineering problem). `stage` distinguishes substages within N8: `SectionParsing` (missing `[[ ## field ## ]]` markers), `Coercion` (jsonish can't parse field value), `PathAssembly` (nested structure mismatch). N13 failures use stage `TypeConversion` (BamlValue→typed output mismatch). Error Display includes full LM response text. | access | — | ← N8, ← N13 | F5, F7 | +| | | | | | | | | +| **U17** | P2 | `augmentation` | `#[derive(Augmentation)]` on struct | compile | → N14 | — | F3 | +| **U18** | P2 | `augmentation` | `#[augment(output, prepend)]` attribute | compile | → N14 | — | F3 | +| **U19** | P2 | `augmentation` | `Augmented` in type position | compile | — | — | F3 | +| **U20** | P2 | `augmentation` | `WithReasoning` generated wrapper type | access | — | ← N14 | F3 | +| **U21** | P2 | `signature` | `#[derive(Signature)]` with generic type params | compile | → N15 | — | F12 | +| **U22** | P2 | `signature` | `#[flatten]` on fields | compile | → N15 | — | F12 | +| **U23** | P2 | `adapter` | `ChatAdapter::build_system(schema, override)` | call | → N3 | → String | F7 | +| **U24** | P2 | `adapter` | `ChatAdapter::format_input(schema, &input)` | call | → N8 (formatting internals) | → String | F7 | +| **U25** | P2 | `adapter` | `ChatAdapter::parse_sections(content)` | call | — | → IndexMap | F7 | +| **U26** | P2 | `adapter` | `ChatAdapter::parse_output::(schema, &response)` | call | → N8 (coercion internals), → N13 | → Result\ | F7 | +| **U27** | P2 | `module` | `impl Module for MyModule { type Input; type Output; fn forward() }` | compile | — | — | F4 | +| **U28** | P2 | — | `Predict>` as internal field | compile | — | — | F3, F5 | +| **U29** | P2 | — | `#[derive(Facet)]` on module struct | compile | — | — | F6 | +| | | | | | | | | +| **U30** | P3 | `discovery` | `named_parameters(&mut module)` — takes exclusive `&mut` access | call | → N18 | → U31 | F6 | +| **U31** | P3 | `discovery` | `Vec<(String, &mut dyn DynPredictor)>` return — mutable handles for optimizer mutation | access | → U32–U37 | ← N18 | F6 | +| **U32** | P3 | `dyn_predictor` | `predictor.schema()` | call | — | → &SignatureSchema | F8 | +| **U33** | P3 | `dyn_predictor` | `predictor.demos_as_examples()` | call | → N21 | → Vec\ | F8 | +| **U34** | P3 | `dyn_predictor` | `predictor.set_demos_from_examples(demos)` | call | → N22 | → Result\<()\> | F8 | +| **U35** | P3 | `dyn_predictor` | `predictor.instruction()` / `set_instruction(s)` | call | → S3 | → String | F8 | +| **U36** | P3 | `dyn_predictor` | `predictor.dump_state()` / `load_state(state)` | call | → S2, → S3 | → PredictState | F8 | +| **U37** | P3 | `dyn_predictor` | `predictor.forward_untyped(BamlValue)` | call | → N23, → N8 | → Result\ | F8 | +| | | | | | | | | +| **U38** | P4 | `registry` | `registry::create(name, &schema, config)` | call | → N17, → S7 | → Box\ | F9 | +| **U39** | P4 | `registry` | `registry::list()` | call | → S7 | → Vec\<&str\> | F9 | +| **U40** | P4 | `dyn_module` | `dyn_module.predictors()` / `predictors_mut()` | call | — | → Vec\<(&str, &dyn DynPredictor)\> | F9 | +| **U41** | P4 | `graph` | `ProgramGraph::new()` | construct | → S5, → S6 | — | F10 | +| **U42** | P4 | `graph` | `graph.add_node(name, node)` | call | → S5 | → Result | F10 | +| **U43** | P4 | `graph` | `graph.connect(from, from_field, to, to_field)` | call | → N24, → S6 | → Result | F10 | +| **U44** | P4 | `graph` | `graph.replace_node(name, node)` | call | → S5, → N24 | → Result | F10 | +| **U45** | P4 | `graph` | `graph.execute(input).await` | call | → N25, → N26 | → Result\ | F10 | +| **U46** | P4 | `graph` | `ProgramGraph::from_module(&module)` | call | → N18 (reuses F6 walker) | → ProgramGraph | F10 | + +--- + +## Code Affordances (Internal Mechanisms) + +| # | Place | Module | Affordance | Control | Wires Out | Returns To | Part | +|---|-------|--------|------------|---------|-----------|------------|------| +| **N1** | P1 | `signature` (macro) | Proc macro expansion — generates `QAInput`, `QAOutput` structs + `impl Signature` | compile | → U4, → U5 | — | F1 | +| **N2** | P1 | `signature` (macro) | Extract doc comment → `fn instructions() -> &'static str` | compile | — | → N8 | F1 | +| **N3** | P1 | `schema` | `SignatureSchema::of::()` — TypeId-keyed cached derivation. Internally: walk_fields (Facet shape walk, flatten-aware), build_type_ir (TypeIR from Shape), build_output_format (OutputFormatContent). Pure pipes collapsed — swapping internals changes no wiring. | cache | → S1 | → N8, → U23–U26 | F2 | +| **N8** | P1 | `adapter` | Predict call pipeline: build_system → format_demos → format_input → lm.call → parse_sections → jsonish coerce → path assembly. Internally uses format_value, navigate_path, insert_at_path, jsonish::from_str (all collapsed — pure pipes). **Error boundary for coercion:** produces `PredictError::Parse` with raw content + field name + coercion detail when LM output doesn't parse. LM resolution: scoped context (`dsrs::with_lm`) > global default (`GLOBAL_SETTINGS`). | call | → N3, → S2 (read demos), → N13, → LM | → U10, → U49 (on error) | F5, F7 | +| **N13** | P1 | `adapter` | `O::try_from_baml_value()` — BamlValue → typed output. **Error boundary:** rejects structurally invalid BamlValue (constraint violations, missing fields). Distinct from N8 coercion errors: N8 = "couldn't understand LM text", N13 = "understood it but doesn't match expected type." | compute | — | → U10 | F7 | +| | | | | | | | | +| **N14** | P2 | `augmentation` (macro) | Augmentation proc macro — generates `WithX` + `Deref` + `impl Augmentation`. Includes tuple composition: `impl Augmentation for (A, B)` provides `(A, B)::Wrap = A::Wrap>` via GATs (type-level only, no code generation — collapsed from former N16). | compile | → U20 | — | F3 | +| **N15** | P2 | `signature` (macro) | Generic signature macro — `split_for_impl()`, generic param threading, flatten handling | compile | → U4, → U5 (generic variants) | — | F12 | +| **N17** | P2/P4 | `dyn_module` | Schema transformation — factory modifies `SignatureSchema` (prepend reasoning, build action schema, etc.) | compute | → N3 | → U38 | F9 | +| | | | | | | | | +| **N18** | P3 | `discovery` | `walk_value()` — recursive struct-field traversal via Facet reflection. Internally: checks `dsrs::parameter` on each Shape, extracts `PredictAccessorFns` payload and casts to `&mut dyn DynPredictor` (one audited unsafe boundary — pointer cast through known-layout Shape attribute). Errors on containers (Vec, Option, HashMap) whose inner type has `dsrs::parameter`. | walk | — | → U31 | F6, F8 | +| **N21** | P3 | `dyn_predictor` | `Demo → Example` — `to_baml_value()` on input + output | convert | — | → U33 | F8 | +| **N22** | P3 | `dyn_predictor` | `Example → Demo` — `try_from_baml_value()` gatekeeper (type safety boundary) | convert | → N23 | → S2 | F8 | +| **N23** | P3 | `dyn_predictor` | `S::Input::try_from_baml_value(input)` — typed conversion for forward_untyped | convert | → N8 | → U37 | F8 | +| | | | | | | | | +| **N24** | P4 | `graph` | `TypeIR::is_assignable_to(&to_type)` — edge type validation at connection time | validate | — | → U43, → U44 | F10 | +| **N25** | P4 | `graph` | Topological sort — determine execution order from edges | compute | → S5, → S6 | → N26 | F10 | +| **N26** | P4 | `graph` | BamlValue piping — route output fields to input fields between nodes | compute | → each node's DynModule::forward() | → U45 | F10 | +| **N27** | P4 | `registry` | `inventory::submit!` — auto-registration of StrategyFactory at link time | generate | → S7 | — | F9 | + +--- + +## Data Stores + +| # | Place | Store | Type | Written By | Read By | Part | +|---|-------|-------|------|------------|---------|------| +| **S1** | P1 (shared) | `TypeId`-keyed schema cache | Global cache, one entry per `S: Signature`. NOT a simple `OnceLock` in a generic fn (that's one static shared across all monomorphizations — a bug). Implemented as `TypeId → &'static SignatureSchema` map behind a lock, or via `generic_once_cell` pattern. Write-once, immutable after init. | N3 (first call from any Place — P1/P2/P3/P4 can all trigger init) | N3, N8, U23–U26, U32, N17, N24 | F2 | +| **S2** | P1 | `demos: Vec>` | Per-Predict demo storage | U6/U7 (init), U34/N22 (optimizer), U36 (load_state) | N8 (format demos), U33/N21 (as examples), U36 (dump_state) | F5 | +| **S3** | P1 | `instruction_override: Option` | Per-Predict instruction | U7 (init), U35 (optimizer) | N8 (build_system), U35 (read), U36 (state) | F5 | +| **S4** | P1 | `tools: Vec>` | Per-Predict tool set | U7/U14 (init) | N8 (lm.call) | F5 | +| **S5** | P4 | `nodes: IndexMap` | Graph node storage | U42, U44, U46 | N25, N26, U45 | F10 | +| **S6** | P4 | `edges: Vec` | Graph edge storage | U43 | N25, N26 | F10 | +| **S7** | P4 (global) | Strategy registry | `name → &'static dyn StrategyFactory` | N27 (link time) | U38, U39 | F9 | + +--- + +## Wiring Narratives + +### P1 Workflow: "Define a signature, create a module, call it, access the result" + +``` +U1 (#[derive(Signature)]) + U2 (#[input]/#[output]) + U3 (doc comment) + → N1 (proc macro expansion — compile time) + → generates U4 (QAInput type) + U5 (QAOutput type) + +U6 (Predict::new()) → initializes S2 (empty demos), S3 (None instruction) + — or — +U7 (builder) + U8 (Demo) → writes S2, S3, S4 + +U9 (module.forward(input)) + → N3 (SignatureSchema::of::()) → S1 (TypeId cache: cached or init) + → N8 (adapter pipeline) + → reads S2 (demos), LM from scoped context or global default + → build system prompt, format demos, format input + → LM provider (external call) + → parse sections, jsonish coerce, path assembly (all internal to N8) + → N13 (try_from_baml_value — error boundary: BamlValue → typed output) + → U10 (Result) + → on error: U49 (PredictError with raw response + stage) + +U10 → U5 (typed output) → U11 (result.answer) or U12 (result.reasoning via Deref) + +U48 (dsrs::forward_all(&module, inputs, concurrency)) + → N8 (×N, buffer_unordered) → Vec> + Individual failures don't abort the batch. + +U51 (module.map(|output| transform(output))) + → constructs Map wrapper (no new wiring — pure value construction) + → the returned Module delegates forward() to inner via existing U9→N8 path + → Map has manual Facet impl: walker sees through to inner Predict leaves + → avoids impl Module for simple post-processing (P1→P2 ramp) +``` + +### P2 Workflow: "Author a new augmentation" + +``` +U17 (#[derive(Augmentation)]) + U18 (#[augment(output, prepend)]) + → N14 (macro expansion — compile time) + → generates U20 (WithReasoning wrapper with Deref) + +Module author writes: + U28 (Predict> as internal field) + + U29 (#[derive(Facet)] on module struct) + + U27 (impl Module for MyModule) + (all compile — compiler validates type constraints immediately) + +Inside forward(), module author calls: + U23 (build_system) → N3 (schema) + U24 (format_input) → N8 internals (format_value, navigate_path) + U26 (parse_output) → N8 internals (jsonish coerce, path assembly) → N13 + — or simply delegates to internal Predict::call() (most common path) +``` + +### P3 Workflow: "Discover and optimize parameters" + +``` +U50 (optimizer.compile(&mut module, trainset, metric)) + → exclusive &mut access — no concurrent forward() during optimization + + U30 (named_parameters(&mut module)) + → N18 (walk_value: recurse through struct fields via Facet reflection, + check dsrs::parameter attr, extract PredictAccessorFns, + cast to &mut dyn DynPredictor — one audited unsafe boundary) + → U31 (Vec<(path, &mut dyn DynPredictor)>) + + For each discovered predictor: + U32 (predictor.schema()) → S1 (understand field structure) + U33 (demos_as_examples) → N21 (Demo→Example via to_baml_value) + — optimizer manipulates examples — + U34 (set_demos_from_examples) → N22 (Example→Demo via try_from_baml_value) + → N22 is the TYPE SAFETY GATEKEEPER: mismatched schema → error, not silent data loss + → S2 (demos overwritten on the original typed Predict) + + U35 (set_instruction) → S3 (instruction overwritten) + U37 (forward_untyped) → N23 (BamlValue → typed input) → N8 (normal call pipeline) + → optimizer uses this for evaluation loops + → may use dsrs::with_lm(cheap_lm, || ...) for scoped override during eval +``` + +### P4 Workflow: "Build and execute a dynamic graph" + +``` +U41 (ProgramGraph::new()) → S5 (empty nodes), S6 (empty edges) + +U38 (registry::create("chain_of_thought", &schema, config)) + → S7 (lookup factory) → N17 (schema transformation) → Box + +U42 (graph.add_node("cot", node)) → S5 + +U43 (graph.connect("input", "question", "cot", "question")) + → N24 (TypeIR::is_assignable_to) → S6 (edge stored if valid) + +U44 (graph.replace_node("cot", new_node)) → S5, re-validates via N24 +U46 (ProgramGraph::from_module(&module)) → N18 (reuses F6 walker) → auto-populates S5/S6 + +U45 (graph.execute(input)) + → N25 (topological sort from S5 + S6) + → N26 (pipe BamlValues between nodes following edges) + → each node's DynModule::forward() + → Result +``` + +### Cross-Place Wiring + +``` +P1 → P3: U50 (optimizer.compile(&mut module, trainset, metric)). + Exclusive &mut borrow — P1 cannot call forward() during optimization. + Optimizer calls U30 (named_parameters), which uses N18 (walker) + to reach INTO the P1 module's Predict leaves. + N18 (walker) casts to &mut dyn DynPredictor — this is the P1→P3 boundary crossing. + After optimization, S2/S3 are mutated but the typed module is unchanged. + +P3 → P1: After optimization, &mut borrow released. + User calls U9 (module.forward()) as normal. + The module reads from S2/S3 which now contain optimized demos/instructions. + No code change in P1 — optimization is invisible. + +P1 → P4: Typed module projected into graph. + U46 (ProgramGraph::from_module) calls N18 (same walker as P3) + to discover Predict leaves and create graph nodes. + +P4 → P3: Graph nodes contain DynModules with internal predictors. + U40 (dyn_module.predictors()) exposes them for P3-style optimization. + +S1 (SignatureSchema cache) is the shared backbone: + Written once by N3, read by all Places — N8 (P1 adapter pipeline), + U23-U26 (P2 building blocks), U32 (P3 schema access), + N17 (P4 schema transformation), N24 (P4 edge validation). + Any Place can trigger first init — it's idempotent and immutable after. +``` + +--- + +## Vertical Slices + +Each slice is a demo-able increment that demonstrates a mechanism working. Slices cut through all layers (types, logic, data) to deliver a working feature. Every affordance is assigned to the slice where it is first needed. + +### Slice Summary + +| # | Slice | Mechanisms | Demo | +|---|-------|-----------|------| +| **V1** | Typed call | F1, F2, F5, F7 | "Define QA, create Predict, call it, get typed result with structured output parsing." | +| **V2** | Augmentation + ChainOfThought | F3, F11(CoT) | "Use ChainOfThought, get result.reasoning and result.answer via Deref." | +| **V3** | Module authoring | F4(full), F12 | "Author a custom two-step module with generic signatures. Adapter building blocks available." | +| **V4** | ReAct + operational | F11(ReAct) | "ReAct with tool builder. Batch calls. .map() transforms output." | +| **V5** | Optimizer interface | F6, F8 | "Discover Predict leaves, mutate demos/instructions, verify effect. Dump/load state." | +| **V6** | Dynamic graph | F9, F10 | "Build graph from registry, validate edges, execute. Project typed module into graph." | + +### Dependency Graph + +``` +V1 ← V2 ← V3 ← V4 +V1 ← V2 ← V5 +V1 ← V2 ← V3 ← V4 ← V6 +V1 ← V5 ← V6 +``` + +V5 (optimizer) depends on V2 (needs augmented modules to test multi-level discovery), not on V3/V4. V6 (dynamic graph) depends on V5 (graph nodes expose predictors via DynPredictor). + +### Slice Details + +**V1: Typed call** — F1, F2, F5, F7 + +| # | Affordance | Slice Role | +|---|------------|------------| +| U1, U2, U3 | Signature derive + markers + doc comment | Entry point | +| U4, U5 | Generated QAInput / QAOutput types | Compile-time output | +| U6, U7, U8 | Predict construction + builder + Demo | Module setup | +| U9, U10, U11 | forward(), Result, field access | Call and result | +| U49 | PredictError variants | Error path | +| N1, N2 | Proc macro expansion, doc extraction | Compile-time mechanisms | +| N3 | SignatureSchema derivation | Schema cache | +| N8 | Adapter pipeline | Core call mechanism | +| N13 | try_from_baml_value | Type conversion boundary | +| S1, S2, S3 | Schema cache, demos, instruction | State | + +Demo program: +```rust +#[derive(Signature, Clone, Debug)] +/// Answer questions accurately. +struct QA { + #[input] question: String, + #[output] answer: String, +} + +let predict = Predict::::new(); +let result = predict.forward(QAInput { question: "What is 2+2?".into() }).await?; +println!("{}", result.answer); // typed field access +``` + +**V2: Augmentation + ChainOfThought** — F3, F11(CoT) + +| # | Affordance | Slice Role | +|---|------------|------------| +| U12 | Deref to augmented field | Augmented output access | +| U13 | ChainOfThought::new() | Library module | +| U16 | Strategy swap (type annotation change) | Composability demo | +| U17, U18, U19, U20 | Augmentation derive + attributes + wrapper | Augmentation system | +| U28, U29 | Predict\ field + derive(Facet) | Module internals | +| N14 | Augmentation macro (incl. tuple composition) | Compile-time mechanism | + +Demo program: +```rust +let cot = ChainOfThought::::new(); +let result = cot.forward(QAInput { question: "What is 2+2?".into() }).await?; +println!("Reasoning: {}", result.reasoning); +println!("Answer: {}", result.answer); // via Deref +``` + +**V3: Module authoring** — F4(full), F12 + +| # | Affordance | Slice Role | +|---|------------|------------| +| U21, U22 | Generic signature derive + #[flatten] | Generic type support | +| U23, U24, U25, U26 | ChatAdapter building blocks | Fine-grained adapter control | +| U27 | impl Module for MyModule | Module trait | +| N15 | Generic signature macro | Compile-time mechanism | + +Demo program: +```rust +#[derive(Facet)] +struct SimpleRAG { + retrieve: Predict, + answer: ChainOfThought, +} + +impl Module for SimpleRAG { + type Input = QAInput; + type Output = WithReasoning; + async fn forward(&self, input: QAInput) -> Result { + let ctx = self.retrieve.forward(RetrieveInput { query: input.question.clone() }).await?; + self.answer.forward(QAWithContextInput { question: input.question, context: ctx.passages }).await + } +} +``` + +**V4: ReAct + operational affordances** — F11(ReAct) + +ReAct is the one library module beyond ChainOfThought. It exercises generic signatures (F12), tools (S4), adapter building blocks (F7), and multi-Predict composition (action + extract steps with a loop inside forward()). If ReAct works, the system handles every augmentation pattern. BestOfN/Refine/ProgramOfThought/MultiChainComparison are deferred — they're consumers of the same mechanisms ReAct proves. + +| # | Affordance | Slice Role | +|---|------------|------------| +| U14 | ReAct builder with tools | Library module | +| U48 | dsrs::forward_all (batching) | Operational | +| U51 | module.map() combinator | Operational | +| S4 | Tools storage | State | + +Demo program: +```rust +// ReAct with tools — exercises generic sigs, tool builder, adapter building blocks, multi-Predict +let react = ReAct::::builder() + .tool("search", "Search the web", search_fn) + .build(); +let result = react.forward(QAInput { question: "Who won the 2024 election?".into() }).await?; + +// Batch 10 inputs concurrently +let results = dsrs::forward_all(&react, inputs, 5).await; + +// Transform output without impl Module +let confident = cot.map(|r| ConfidentAnswer { answer: r.answer.clone(), confidence: 0.9 }); +``` + +**V5: Optimizer interface** — F6, F8 + +| # | Affordance | Slice Role | +|---|------------|------------| +| U50 | optimizer.compile(&mut module, ...) | P1→P3 entry | +| U30, U31 | named_parameters, handle vec | Discovery | +| U32 | predictor.schema() | Schema access | +| U33, U34 | demos_as_examples / set_demos | Demo mutation | +| U35 | instruction / set_instruction | Instruction mutation | +| U36 | dump_state / load_state | State persistence | +| U37 | forward_untyped | Untyped evaluation | +| N18 | walk_value (Facet walker) | Discovery mechanism | +| N21, N22, N23 | Type conversions (Demo↔Example, BamlValue→typed) | Conversion boundaries | + +Demo program: +```rust +let mut module = SimpleRAG::new(); // from V3 + +// Discover all Predict leaves — no annotations needed +let params = named_parameters(&mut module); +assert_eq!(params.len(), 2); // retrieve.predict + answer.predict + +// Mutate demos +params[0].1.set_demos_from_examples(new_demos)?; +params[1].1.set_instruction("Be concise.".into()); + +// Verify mutations took effect +let result = module.forward(input).await?; + +// Save optimized state to disk +let state = dsrs::dump_state(&module); +std::fs::write("optimized.json", serde_json::to_string(&state)?)?; +``` + +**V6: Dynamic graph** — F9, F10 + +| # | Affordance | Slice Role | +|---|------------|------------| +| U38, U39 | registry::create, registry::list | Factory interface | +| U40 | dyn_module.predictors() | Predictor access | +| U41, U42, U43, U44 | ProgramGraph construction + mutation | Graph building | +| U45 | graph.execute() | Graph execution | +| U46 | ProgramGraph::from_module() | Typed→graph projection | +| N17 | Schema transformation | Factory mechanism | +| N24 | Edge type validation | Validation mechanism | +| N25, N26 | Topological sort + BamlValue piping | Execution mechanism | +| N27 | inventory::submit! (auto-registration) | Registration mechanism | +| S5, S6, S7 | Nodes, edges, registry | State | + +Demo program: +```rust +let mut graph = ProgramGraph::new(); +let cot = registry::create("chain_of_thought", &schema, Default::default())?; +graph.add_node("cot", cot)?; +graph.connect("input", "question", "cot", "question")?; +let result = graph.execute(BamlValue::from_map([("question", "What is 2+2?")])).await?; +``` diff --git a/docs/specs/modules/design_reference.md b/docs/specs/modules/design_reference.md new file mode 100644 index 00000000..107ca79c --- /dev/null +++ b/docs/specs/modules/design_reference.md @@ -0,0 +1,1045 @@ +# DSRs Module System — Technical Design Reference + +> Companion to the Shaping Document. The shaping doc says **what** we want (R's) and **what parts** we need (F's). This document captures **how each part works**: the concrete types, traits, data flow, code sketches, and design decisions from the shaping process. + +--- + +## Table of Contents + +1. [Design Principles](#1-design-principles) +2. [Signature: The Typed Contract (F1, F12)](#2-signature) +3. [SignatureSchema: Facet-Derived Metadata (F2)](#3-signatureschema) +4. [Augmentation: Typed Signature Extension (F3)](#4-augmentation) +5. [Module Trait: The Composition Interface (F4)](#5-module-trait) +6. [Predict: The Leaf Parameter (F5)](#6-predict) +7. [Facet-Powered Parameter Discovery (F6)](#7-parameter-discovery) +8. [Adapter Building Blocks (F7)](#8-adapter) +9. [DynPredictor: The Optimizer Bridge (F8)](#9-dynpredictor) +10. [DynModule + StrategyFactory (F9)](#10-dynmodule) +11. [ProgramGraph (F10)](#11-programgraph) +12. [Library Modules (F11)](#12-library-modules) +13. [Layer Architecture](#13-layers) +14. [Key Design Decisions](#14-decisions) +15. [Resolved Spikes](#15-spikes) +16. [Typed vs Dynamic Path Summary](#16-typed-vs-dynamic) + +--- + +## 1. Design Principles + +These emerged through the conversation and should guide all implementation choices: + +**Facet Shapes are the source of truth.** Field names, types, order, docs, constraints — all derived from Facet at runtime. No parallel schema systems. Macro-emitted static `FieldSpec` arrays are replaced by `SignatureSchema::of::()`. + +**Parse, don't validate.** A `ProgramGraph` with edges has already been type-checked at connection time. An augmented output type with `#[flatten]` encodes the field expansion in the type — the adapter doesn't need to know about augmentation, it just walks the Shape. + +**If it requires vigilance, the design is leaking.** Module authors don't annotate `#[parameter]` on predictors. They don't implement traversal. They `#[derive(Facet)]` and the walker finds everything. The structure IS the declaration. + +**Modules are prompting strategies.** ChainOfThought doesn't modify your code — it modifies the prompt. The Signature type changes (augmented output), but the user's data contract stays the same. `result.answer` works whether you used Predict or ChainOfThought. + +**Typed path is primary, dynamic path is escape hatch.** Layer 1 (typed modules) is where 90% of programs live. Layer 3 (dynamic graph) exists for structural optimization and interop. Both paths share the same adapter and prompt format. + +**One adapter, one prompt format.** Both the typed path and the dynamic graph path use `SignatureSchema` → ChatAdapter building blocks → `[[ ## field ## ]]` delimited prompts. Identical prompts for identical logical signatures regardless of which path produced them. + +--- + +## 2. Signature: The Typed Contract (F1, F12) + +### The trait + +```rust +pub trait Signature: Send + Sync + 'static { + type Input: BamlType + Facet + Send + Sync; + type Output: BamlType + Facet + Send + Sync; + + fn instructions() -> &'static str { "" } +} +``` + +Bounds: `BamlType` for jsonish coercion and value conversion. `Facet` for schema derivation. Both are derived, not manual. + +Note: `from_parts`/`into_parts` were removed from the trait (S7). The current codebase uses them to combine input+output into one struct and split back apart, but with demos stored as `Demo { input: S::Input, output: S::Output }` pairs and `Predict::call()` returning `S::Output` directly, the round-trip is unnecessary. The user's `#[derive(Signature)]` still generates the combined struct for ergonomic field access, but that's a convenience on the user's type, not a trait requirement. + +### User-facing derive + +```rust +/// Answer questions accurately and concisely. +#[derive(Signature, Clone, Debug)] +struct QA { + /// The question to answer + #[input] + question: String, + + /// A clear, direct answer + #[output] + answer: String, +} +``` + +### What the derive generates + +```rust +// Public input type — users construct this to call the module +#[derive(Clone, Debug, Facet, BamlType)] +pub struct QAInput { + /// The question to answer + pub question: String, +} + +// Output type — returned from the module +#[derive(Clone, Debug, Facet, BamlType)] +pub struct QAOutput { + /// A clear, direct answer + pub answer: String, +} + +// Signature impl +impl Signature for QA { + type Input = QAInput; + type Output = QAOutput; + + fn instructions() -> &'static str { + "Answer questions accurately and concisely." // from struct doc comment + } +} +``` + +Key change from current: the derive does NOT emit static `FieldSpec` arrays or `OutputFormatContent`. Those are derived from Facet Shapes at runtime (F2). + +### Generic signatures (F12) + +Module authors define signatures with type parameters for reuse: + +```rust +#[derive(Signature, Clone)] +#[instruction = "Write Python code that produces the answer."] +struct CodeGen { + #[input, flatten] base: I, + #[output] generated_code: String, +} +``` + +The derive generates `CodeGenInput` with I's fields flattened inline, plus `generated_code` as output. S1 confirmed this is feasible — the proc macro needs `split_for_impl()` to thread type parameters and bounds through generated types. Decision: Option C (full replacement) — `SignatureSchema` derived from Facet replaces `FieldSpec` entirely, no incremental migration (see `S1-generic-signature-derive.md`). + +### Attributes (moving to Facet long-term) + +Current: `#[input]`, `#[output]`, `#[alias = "..."]`, `#[check("...")]`, `#[assert("...")]`, `#[format("...")]`. + +Long-term direction: these become Facet attributes via `define_attr_grammar!`: + +```rust +define_attr_grammar! { + pub mod dsrs { + input, + output, + flatten, + alias(String), + format(String), + check(expr: String, label: String), + assert_constraint(expr: String, label: Option), + parameter, // marks Predict for discovery + } +} +``` + +This is a migration, not a blocker. Current custom attributes work. Facet attributes are the end state. + +--- + +## 3. SignatureSchema: Facet-Derived Metadata (F2) + +### The type + +```rust +pub struct SignatureSchema { + pub instruction: String, + pub input_fields: Vec, + pub output_fields: Vec, + pub output_format: Arc, +} + +pub struct FieldSchema { + pub name: String, // LM-facing name (alias or field name) + pub rust_name: String, // Rust field identifier + pub type_ir: TypeIR, // for jsonish coercion + pub facet_shape: fn() -> &'static Shape, // full Rust type info + pub desc: String, // from doc comment + pub path: FieldPath, // e.g. ["inner", "answer"] for flattened fields + pub constraints: Vec, + pub format: Option, // input formatting hint +} + +pub struct FieldPath(pub Vec); +``` + +### Derivation + +```rust +impl SignatureSchema { + pub fn of() -> &'static Self { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(|| { + Self::derive::() + }) + } + + fn derive() -> Self { + SignatureSchema { + instruction: S::instructions().to_string(), + input_fields: walk_fields::(/* dsrs::input */), + output_fields: walk_fields::(/* dsrs::output */), + output_format: Arc::new(build_output_format::()), + } + } +} +``` + +### The Facet walk (flatten-aware) + +```rust +fn walk_fields(prefix_path: &[String]) -> Vec { + let shape = T::SHAPE; + let mut fields = Vec::new(); + + // shape.def → StructType → iterate fields in declaration order + for field in struct_fields(shape) { + let path = [prefix_path, &[field.name.to_string()]].concat(); + + // S8: Facet exposes flatten as field.is_flattened() — O(1) flag check. + // field.shape() returns the inner type's Shape for recursion. + if field.is_flattened() { + // Recurse into the flattened type — inline its fields at this level + let inner_shape = field.shape.get(); // ShapeRef → Shape + fields.extend(walk_fields_from_shape(inner_shape, &path)); + } else { + fields.push(FieldSchema { + name: effective_name(field), // alias or field name + rust_name: field.name.to_string(), + type_ir: build_type_ir(field.shape.get()), + facet_shape: field.shape, + desc: field.doc.join(" "), + path: FieldPath(path), + constraints: extract_constraints(field), + format: extract_format(field), + }); + } + } + + fields +} +``` + +**The flatten walk is the core trick.** When the schema builder encounters `#[flatten]`, it doesn't emit one field — it recurses and emits the inner type's fields with extended paths. This is how `WithReasoning` produces fields `[reasoning, answer]` instead of `[reasoning, inner]`. + +### What replaces current FieldSpec arrays + +| Current | New | +|---------|-----| +| `static __QA_INPUT_FIELDS: &[FieldSpec]` | `SignatureSchema::of::().input_fields` | +| `static __QA_OUTPUT_FIELDS: &[FieldSpec]` | `SignatureSchema::of::().output_fields` | +| `FieldSpec { type_ir: fn() -> TypeIR }` | `FieldSchema { type_ir: TypeIR, facet_shape: fn() -> &Shape }` | +| `S::output_format_content()` | `SignatureSchema::of::().output_format` | + +The `Signature` trait no longer exposes field-level methods. It provides types + instruction. `SignatureSchema` provides everything else, derived from those types via Facet. + +--- + +## 4. Augmentation: Typed Signature Extension (F3) + +### The augmentation trait + +```rust +pub trait Augmentation: 'static { + type Wrap: BamlType + Facet + Deref; +} +``` + +An Augmentation knows how to wrap any output type. `Deref` is the key — it makes the inner type's fields accessible through the wrapper. + +### The derive + +```rust +#[derive(Augmentation)] +#[augment(output, prepend)] +pub struct Reasoning { + /// Step-by-step reasoning + reasoning: String, +} +``` + +Generates: + +```rust +// The wrapper type +#[derive(Clone, Debug, Facet, BamlType)] +pub struct WithReasoning { + /// Step-by-step reasoning + pub reasoning: String, + + #[facet(dsrs::flatten)] + pub inner: O, +} + +impl Deref for WithReasoning { + type Target = O; + fn deref(&self) -> &O { &self.inner } +} + +impl WithReasoning { + pub fn into_inner(self) -> O { self.inner } +} + +// The augmentation trait impl +impl Augmentation for Reasoning { + type Wrap = WithReasoning; +} +``` + +### The signature combinator + +```rust +pub struct Augmented(PhantomData<(S, A)>); + +impl Signature for Augmented { + type Input = S::Input; + type Output = A::Wrap; + + fn instructions() -> &'static str { S::instructions() } +} +``` + +`Augmented` is a zero-sized type-level combinator. It exists purely to map `S::Input → A::Wrap` at the type level. Modules hold `Predict>` where demos are stored as `Demo { input: S::Input, output: A::Wrap }` pairs and `call()` returns `A::Wrap` directly. No `from_parts`/`into_parts` needed (S7). + +### How BamlType works for flatten + +`WithReasoning` implements `BamlType` with flatten semantics. Facet drives both directions: + +**Serialization (for demos, tracing):** `to_baml_value()` produces a flat `BamlValue::Class` with reasoning + O's fields merged. The Facet shape walk on `WithReasoning` expands flatten, so the schema builder already knows the flat field list. + +**Deserialization (from LM output):** The adapter parses flat sections `{reasoning: "...", answer: "..."}`. Construction uses the `FieldPath` from `SignatureSchema`: +- `reasoning` → path `["reasoning"]` → set on WithReasoning directly +- `answer` → path `["inner", "answer"]` → navigate into inner, set on O + +This could use Facet's `Partial` for progressive construction, or the simpler BamlValue intermediary: +1. Build nested `BamlValue` following paths +2. Call `BamlType::try_from_baml_value()` on WithReasoning + +Both work. BamlValue intermediary is simpler; Partial is more efficient for streaming. Decision deferred. + +### User-facing field access + +```rust +let cot = ChainOfThought::::new(); +let result = cot.call(QAInput { question: "2+2?".into() }).await?; + +result.reasoning // String — direct field on WithReasoning +result.answer // String — Deref to QAOutput → .answer +result.question // NOT available (that's on the input, not output) +``` + +Type inference handles everything. The user never writes `WithReasoning` unless they're naming a return type explicitly. Even then, it reads as English: "QA output, with reasoning." + +### Composition (R13, S3 resolved) + +```rust +// Tuple augmentation +impl Augmentation for (A, B) { + type Wrap = A::Wrap>; +} + +// Usage: +type CotWithConfidence = Augmented; +// Output: WithReasoning> +``` + +Field access: `result.reasoning` works directly. `result.confidence` requires Deref through WithReasoning to WithConfidence. `result.answer` requires double Deref. S3 confirmed: Rust auto-Deref resolves field access and method calls through multiple wrapper layers. Pattern matching does NOT auto-deref — it requires explicit layer-by-layer destructuring, which is an acceptable documented limitation. Mutability contract: `Deref`-only unless `DerefMut` is proven necessary (see `S3-augmentation-deref-composition.md`). + +--- + +## 5. Module Trait: The Composition Interface (F4) + +```rust +#[async_trait] +pub trait Module: Send + Sync { + type Input: BamlType + Facet + Send + Sync; + type Output: BamlType + Facet + Send + Sync; + + async fn forward(&self, input: Self::Input) -> Result; +} +``` + +Every prompting strategy implements this. The associated types make composition type-safe: + +```rust +// The compiler rejects this at compile time: +struct Bad { + step1: Predict, + step2: Predict, // Summarize expects SummarizeInput, not QAOutput +} +// step2.forward(step1_output) → type mismatch → compile error +``` + +### Swapping strategies + +```rust +// These all implement Module +let direct: Predict // Output = QA +let cot: ChainOfThought // Output = WithReasoning +let react: ReAct // Output = QA (or WithTrajectory) +let bon: BestOfN> // Output = WithReasoning + +// Generic over strategy: +struct RAG> { + retrieve: Predict, + answer: A, +} +``` + +--- + +## 6. Predict: The Leaf Parameter (F5) + +```rust +#[derive(Facet)] +#[facet(dsrs::parameter)] // marks for discovery by F6 walker +pub struct Predict { + demos: Vec>, + instruction_override: Option, + tools: Vec>, +} + +/// Demos are input+output pairs — not combined signature structs. +/// This is what makes Augmented work: Demo> +/// stores (QAInput, WithReasoning) without needing from_parts. +pub struct Demo { + pub input: S::Input, + pub output: S::Output, +} +``` + +### Key change from current + +Demos are `Vec>` (typed input + output pair), NOT `Vec` (the full combined signature struct). This makes augmentation work: `Predict>` stores demos where output is `WithReasoning`, so demo assistant messages include reasoning. + +### Builder + +```rust +let predict = Predict::::builder() + .demo(Demo { + input: QAInput { question: "What is 2+2?".into() }, + output: QAOutput { answer: "4".into() }, + }) + .instruction("Answer with exactly one word.") + .build(); +``` + +### The call pipeline + +```rust +impl Predict { + pub async fn call(&self, input: S::Input) -> Result { + let schema = SignatureSchema::of::(); // F2: Facet-derived, cached + let lm = get_global_lm(); + let adapter = ChatAdapter; + + // Build prompt + let system = adapter.build_system(schema, self.instruction_override.as_deref()); + let mut chat = Chat::new(vec![Message::system(system)]); + + // Format demos + for demo in &self.demos { + let user = adapter.format_input(schema, &demo.input); + let assistant = adapter.format_output(schema, &demo.output); + chat.push_message(Message::user(user)); + chat.push_message(Message::assistant(assistant)); + } + + // Format current input + let user = adapter.format_input(schema, &input); + chat.push_message(Message::user(user)); + + // Call LM + let response = lm.call(chat, self.tools.clone()).await?; + + // Parse response + let output = adapter.parse_output::(schema, &response)?; + + Ok(output) + } +} +``` + +### State serialization (R10) + +```rust +impl Predict { + pub fn dump_state(&self) -> PredictState { + PredictState { + demos: self.demos.iter().map(|d| demo_to_example(d)).collect(), + instruction_override: self.instruction_override.clone(), + // signature structure comes from code, not serialized + } + } + + pub fn load_state(&mut self, state: PredictState) -> Result<()> { + self.demos = state.demos.iter() + .map(|e| example_to_demo::(e)) + .collect::>()?; + self.instruction_override = state.instruction_override; + Ok(()) + } +} +``` + +--- + +## 7. Facet-Powered Parameter Discovery (F6) + +### The walker + +```rust +pub fn named_parameters<'a>( + root: &'a dyn Reflect, // or: root with known Facet Shape +) -> Vec<(String, /* handle to predictor */)> { + let mut results = Vec::new(); + walk_value(root, "", &mut results); + results +} + +fn walk_value(value: /* Peek or similar */, path: &str, results: &mut Vec<...>) { + let shape = value.shape(); + + // Check: is this a parameter leaf? + if has_dsrs_parameter(shape) { + results.push((path.to_string(), /* extract DynPredictor handle */)); + return; // don't recurse into Predict's internals + } + + // Recurse based on shape.def + // V1: struct-field recursion only. Container traversal (Option/Vec/HashMap/Box) + // deferred (S5) — all V1 library modules use struct fields. + match shape.def { + Def::Struct(struct_type) => { + for field in struct_type.fields { + let child = value.field(field.name); + let child_path = format!("{}.{}", path, field.name); + walk_value(child, &child_path, results); + } + } + _ => {} // containers, primitives, enums — skip for V1 + } +} +``` + +### What the user writes + +```rust +#[derive(Facet)] +pub struct RAG { + retrieve: Predict, + answer: ChainOfThought, +} +``` + +### What the walker produces + +``` +[ + ("retrieve", handle to Predict), + ("answer.predict", handle to Predict>), +] +``` + +The walker recurses into `ChainOfThought` (a struct with a `predict` field), finds the Predict inside, and reports the dotted path. Identical to DSPy's `named_parameters()` output. + +### How the handle works (S2 resolved: Mechanism A) + +The walker finds a value whose Shape has `dsrs::parameter`. It needs to hand back something the optimizer can call `get_demos()`, `set_demos()`, `set_instruction()` on. + +S2 evaluated three mechanisms and selected **Mechanism A: shape-local accessor payload**. `Predict` carries a `PredictAccessorFns` payload as a typed Facet attribute (fn-pointer based, `'static + Copy`). The walker extracts it via `attr.get_as::()` — the same pattern already used by `WithAdapterFns` in `bamltype/src/facet_ext.rs`. The payload provides a direct cast to `&mut dyn DynPredictor` at the leaf, with one audited unsafe boundary. + +Global registry (Mechanism B) is deferred — only needed if cross-crate runtime loading is later required. Interior dyn-handle state (Mechanism C) was rejected for V1 (see `S2-dynpredictor-handle-discovery.md`). + +--- + +## 8. Adapter Building Blocks (F7) + +### Public API for module authors + +```rust +impl ChatAdapter { + /// Build system message from a SignatureSchema + pub fn build_system( + schema: &SignatureSchema, + instruction_override: Option<&str>, + ) -> String; + + /// Format a typed input value as user message fields + /// Uses Facet Peek to walk the value generically + pub fn format_input( + schema: &SignatureSchema, + input: &I, + ) -> String; + + /// Format a typed output value as assistant message fields + pub fn format_output( + schema: &SignatureSchema, + output: &O, + ) -> String; + + /// Parse [[ ## field ## ]] sections from raw LM response + pub fn parse_sections(content: &str) -> IndexMap; + + /// Parse typed output from sections using SignatureSchema + pub fn parse_output( + schema: &SignatureSchema, + response: &Message, + ) -> Result; +} +``` + +### How format_input uses Facet + +```rust +pub fn format_input( + schema: &SignatureSchema, + input: &I, +) -> String { + let baml_value = input.to_baml_value(); + let fields = baml_value_fields(&baml_value); + + let mut result = String::new(); + for field_schema in &schema.input_fields { + // Navigate the BamlValue using the field's path + if let Some(value) = navigate_path(fields, &field_schema.path) { + result.push_str(&format!("[[ ## {} ## ]]\n", field_schema.name)); + result.push_str(&format_value(value, field_schema.format.as_deref())); + result.push_str("\n\n"); + } + } + + // Append response instructions + result.push_str(&format_response_instructions(&schema.output_fields)); + result +} +``` + +The `field_schema.path` handles flatten navigation. For a flat field like `question`, path is `["question"]`. For a flattened field like `answer` inside `WithReasoning`, path is `["inner", "answer"]`. `navigate_path` follows the path through the BamlValue tree. + +### How parse_output uses field paths + +```rust +pub fn parse_output( + schema: &SignatureSchema, + response: &Message, +) -> Result { + let sections = parse_sections(&response.content()); + + // Build a nested BamlValue following field paths + let mut root = BamlMap::new(); + for field_schema in &schema.output_fields { + let raw = sections.get(&field_schema.name) + .ok_or(ParseError::MissingField { field: field_schema.name.clone() })?; + + // Coerce raw text to typed value via jsonish + let value = jsonish::from_str( + &schema.output_format, + &field_schema.type_ir, + raw, + true, + )?; + + // Insert at path + insert_at_path(&mut root, &field_schema.path, value.into()); + } + + // Convert nested BamlValue to typed output + O::try_from_baml_value(BamlValue::Class( + O::baml_internal_name().to_string(), + root, + )).map_err(|e| ParseError::ExtractionFailed { reason: e.to_string() }) +} +``` + +The `insert_at_path` function creates nested BamlValue::Class entries as needed. For `path = ["inner", "answer"]`, it ensures `root["inner"]` exists as a Class, then inserts `answer` into it. This is how flat LM output maps back to nested Rust types. + +--- + +## 9. DynPredictor: The Optimizer Bridge (F8) + +```rust +pub trait DynPredictor: Send + Sync { + /// The Facet-derived schema for this predictor + fn schema(&self) -> &SignatureSchema; + + /// Current instruction (override or default) + fn instruction(&self) -> String; + fn set_instruction(&mut self, instruction: String); + + /// Demos as untyped Examples (for optimizer manipulation) + fn demos_as_examples(&self) -> Vec; + fn set_demos_from_examples(&mut self, demos: Vec) -> Result<()>; + + /// Parameter state serialization + fn dump_state(&self) -> PredictState; + fn load_state(&mut self, state: PredictState) -> Result<()>; + + /// Untyped forward (for dynamic graph execution) + async fn forward_untyped(&self, input: BamlValue) -> Result; +} +``` + +Every `Predict` implements this. The implementation converts between typed and untyped representations: + +```rust +impl DynPredictor for Predict +where S::Input: BamlType, S::Output: BamlType +{ + fn demos_as_examples(&self) -> Vec { + self.demos.iter().map(|d| { + let input_value = d.input.to_baml_value(); + let output_value = d.output.to_baml_value(); + // Merge into Example with input/output key separation + Example::from_baml_values(input_value, output_value) + }).collect() + } + + fn set_demos_from_examples(&mut self, demos: Vec) -> Result<()> { + self.demos = demos.into_iter().map(|e| { + Ok(Demo { + input: S::Input::try_from_baml_value(e.input_as_baml_value()?)?, + output: S::Output::try_from_baml_value(e.output_as_baml_value()?)?, + }) + }).collect::>()?; + Ok(()) + } + + async fn forward_untyped(&self, input: BamlValue) -> Result { + let typed_input = S::Input::try_from_baml_value(input)?; + let output = self.call(typed_input).await?; + Ok(output.to_baml_value()) + } +} +``` + +**How the Facet walker obtains a `&dyn DynPredictor`** — S2 Mechanism A. The walker detects `dsrs::parameter` on the Shape, extracts the `PredictAccessorFns` payload via typed attr decoding, and uses it to cast the value to `&dyn DynPredictor` (or `&mut dyn DynPredictor` for mutation). See section 7 for walker details. + +**Type safety through the dynamic boundary:** The optimizer manipulates demos as untyped `Example` values, but `DynPredictor` is always backed by a concrete `Predict` that knows its types at compile time. `set_demos_from_examples` converts `Example → Demo` via `S::Input::try_from_baml_value()` / `S::Output::try_from_baml_value()` — if the data doesn't match the schema, this fails with an error, never silent data loss. The typed module is never replaced or wrapped by the optimizer; it reaches IN to the existing `Predict` and mutates state. When the optimizer is done, the user's module still has correctly typed demos because the conversion gatekeeper enforced the schema at every write. + +--- + +## 10. DynModule + StrategyFactory (F9) + +### DynModule + +```rust +#[async_trait] +pub trait DynModule: Send + Sync { + /// The external schema (what callers see) + fn schema(&self) -> &SignatureSchema; + + /// All internal predictors for optimizer discovery + fn predictors(&self) -> Vec<(&str, &dyn DynPredictor)>; + fn predictors_mut(&mut self) -> Vec<(&str, &mut dyn DynPredictor)>; + + /// Execute with untyped values + async fn forward(&self, input: BamlValue) -> Result; +} +``` + +### StrategyFactory + +```rust +pub trait StrategyFactory: Send + Sync { + fn name(&self) -> &str; + fn config_schema(&self) -> StrategyConfigSchema; + fn create( + &self, + base_schema: &SignatureSchema, + config: StrategyConfig, + ) -> Result>; +} +``` + +Factories perform **schema transformations**. `ChainOfThoughtFactory::create(schema)` prepends a `reasoning` field to `schema.output_fields`. `ReActFactory::create(schema, {tools})` builds an action schema and extract schema from the base schema + tool definitions. + +### Registry + +```rust +pub mod registry { + pub fn get(name: &str) -> Result<&'static dyn StrategyFactory>; + pub fn create(name: &str, schema: &SignatureSchema, config: StrategyConfig) -> Result>; + pub fn list() -> Vec<&'static str>; +} +``` + +Module types register factories via `inventory::submit!` or similar distributed static init. + +--- + +## 11. ProgramGraph (F10) + +```rust +pub struct ProgramGraph { + nodes: IndexMap, + edges: Vec, +} + +pub struct Node { + schema: SignatureSchema, + module: Box, +} + +pub struct Edge { + from_node: String, + from_field: String, + to_node: String, + to_field: String, +} +``` + +### Edge validation at insertion time + +```rust +impl ProgramGraph { + pub fn connect(&mut self, from: &str, from_field: &str, to: &str, to_field: &str) -> Result<(), GraphError> { + let from_type = self.nodes[from].schema.output_field(from_field)?.type_ir; + let to_type = self.nodes[to].schema.input_field(to_field)?.type_ir; + + if !from_type.is_assignable_to(&to_type) { + return Err(GraphError::TypeMismatch { /* ... */ }); + } + + self.edges.push(Edge { from_node: from.into(), from_field: from_field.into(), to_node: to.into(), to_field: to_field.into() }); + Ok(()) + } +} +``` + +### Execution + +Topological sort → pipe BamlValues between nodes following edges. Each node's `DynModule::forward()` handles its internal orchestration (single LM call for Predict, multi-step loop for ReAct, etc.). + +### Projection from typed modules + +```rust +impl ProgramGraph { + pub fn from_module(module: &M) -> Self { + let params = named_parameters(module); // F6 walker + let mut graph = ProgramGraph::new(); + for (path, predictor_handle) in params { + graph.add_node(path, Node { + schema: predictor_handle.schema().clone(), + module: /* wrap predictor as DynModule */, + }); + } + // Edges: inferred from trace or explicit annotation + graph + } +} +``` + +--- + +## 12. Library Modules (F11) + +### ChainOfThought + +```rust +#[derive(Augmentation)] +#[augment(output, prepend)] +pub struct Reasoning { + /// Step-by-step reasoning + reasoning: String, +} + +#[derive(Facet)] +pub struct ChainOfThought { + predict: Predict>, +} + +#[async_trait] +impl Module for ChainOfThought { + type Input = S::Input; + type Output = WithReasoning; + + async fn forward(&self, input: S::Input) -> Result, PredictError> { + self.predict.call(input).await + } +} +``` + +### BestOfN + +```rust +#[derive(Facet)] +pub struct BestOfN { + module: M, + n: usize, + threshold: f64, + reward_fn: Box f64 + Send + Sync>, +} + +#[async_trait] +impl Module for BestOfN +where M::Input: Clone +{ + type Input = M::Input; + type Output = M::Output; + + async fn forward(&self, input: M::Input) -> Result { + let mut best = None; + let mut best_score = f64::NEG_INFINITY; + for _ in 0..self.n { + let output = self.module.forward(input.clone()).await?; + let score = (self.reward_fn)(&input, &output); + if score >= self.threshold { return Ok(output); } + if score > best_score { best_score = score; best = Some(output); } + } + best.ok_or(PredictError::AllAttemptsFailed) + } +} +``` + +### ReAct + +Uses generic signature derives (F12) for action and extract steps. Builder API for tools. Action loop uses adapter building blocks (F7) for dynamic trajectory formatting. Extraction step uses `ChainOfThought>`. + +Two Predict leaves: `action` and `extract.predict`. Both discoverable by F6 walker. + +### ProgramOfThought + +Three ChainOfThought modules with custom generic signatures (`CodeGen`, `CodeRegen`, `OutputInterp`). Code execution loop with retry. Three Predict leaves discoverable by walker. + +### Refine + +BestOfN with feedback injection. Scoped context mechanism deferred (S4) — will be determined when Refine is built. Options investigated: `tokio::task_local!` (pragmatic, spawn footgun), explicit `Module::forward` context parameter (correct, invasive), `thread_local!` (rejected — brittle under async concurrency). See `S4-refine-scoped-context.md` for findings. + +### MultiChainComparison + +M source modules (typically ChainOfThought) run in parallel. Results aggregated into a comparison prompt via either a `Vec` field or dynamic schema builder (F7). Comparison predictor produces synthesized output. + +--- + +## 13. Layer Architecture + +``` +Layer 0: Types (always present) + ┌──────────────────────────────────────────┐ + │ Rust types + Facet + BamlType │ + │ Source of truth. In the binary. │ + └──────────────────────────────────────────┘ + +Layer 1: Typed Modules (default experience) + ┌──────────────────────────────────────────┐ + │ Signature (F1) → SignatureSchema (F2) │ + │ Augmentation (F3) → Module trait (F4) │ + │ Predict (F5) → Library modules (F11) │ + │ Generic Signature derive (F12) │ + └──────────────────────────────────────────┘ + +Layer 2: Optimization Bridge (when optimizing) + ┌──────────────────────────────────────────┐ + │ Facet parameter discovery (F6) │ + │ DynPredictor vtable (F8) │ + │ Adapter building blocks (F7) │ + └──────────────────────────────────────────┘ + +Layer 3: Dynamic Graph (opt-in) + ┌──────────────────────────────────────────┐ + │ DynModule + StrategyFactory (F9) │ + │ ProgramGraph (F10) │ + │ Strategy registry │ + └──────────────────────────────────────────┘ +``` + +Each layer only instantiated if needed. Simple usage: Layers 0-1. Optimization: add Layer 2. Structural optimization / interop: add Layer 3. + +--- + +## 14. Key Design Decisions + +| # | Decision | Rationale | Alternatives Rejected | +|---|----------|-----------|----------------------| +| **D1** | Augmentation via wrapper types + `#[flatten]` + `Deref`, not runtime signature manipulation | Type-safe, compile-time checked, Facet does the work | Runtime string-based field manipulation (DSPy-style) — gives up Rust's type system | +| **D2** | `SignatureSchema` derived from Facet at runtime, not emitted by macro as static arrays | Single source of truth (types), no drift between schema and type, flatten works generically | Static `FieldSpec` arrays (current) — can't handle generic augmentation | +| **D3** | Module trait with associated Input/Output types, not trait objects | Compile-time type safety for composition, no runtime type errors | `dyn Module` everywhere — loses all type checking | +| **D4** | Predict is the ONLY leaf parameter, discovered by Facet attribute | Same as DSPy: one optimization surface. Discovery is automatic | Multiple parameter types, manual registration | +| **D5** | Demos stored as typed `Demo` not `Vec` (combined signature struct) | Separates input/output cleanly, augmented demos naturally include strategy metadata | `Vec` (current) — blocks augmented signatures where S is a phantom type | +| **D6** | Dynamic graph (Layer 3) shares `SignatureSchema` and adapter with typed path | One prompt format, validated edges, no divergence between paths | Separate dynamic system — double the code, divergent behavior | +| **D7** | Module authoring: `#[derive(Facet)]` on struct, `impl Module`, done | Zero framework tax. Structure is the declaration. | `#[derive(Optimizable)]` + `#[parameter]` — requires vigilance | +| **D8** | `#[derive(Augmentation)]` generates wrapper + Deref + trait impl | 5 lines for a new augmentation. Reusable across any Signature. | Manual wrapper + manual Signature impl per augmentation (verbose) | +| **D9** | StrategyFactory creates DynModules from name + schema + config | Optimizer can propose arbitrary strategies for graph nodes | Fixed set of typed strategies only — blocks structural optimization | + +--- + +## 15. Spikes + +All spikes have been investigated. Full findings in `spikes/S{n}-*.md`. + +| # | Question | Decision | Spike doc | +|---|----------|----------|-----------| +| **S1** | Generic `#[derive(Signature)]` with `#[flatten]` type parameters | **Option C: full replacement.** Build `SignatureSchema` from Facet, replace `FieldSpec` everywhere, delete the old system. | `S1-generic-signature-derive.md` | +| **S2** | Mechanism for Facet walker to obtain `&dyn DynPredictor` | **Mechanism A**: shape-local accessor payload (`PredictAccessorFns` as typed Facet attr). | `S2-dynpredictor-handle-discovery.md` | +| **S3** | Rust auto-Deref chain for nested wrapper field access | **Works for reads/methods.** Pattern matching not ergonomic (don't care). `Deref`-only. | `S3-augmentation-deref-composition.md` | +| **S4** | Scoped context mechanism for Refine hint injection | **Deferred.** Determined when Refine is built. | `S4-refine-scoped-context.md` | +| **S5** | Facet walker behavior for Option/Vec/HashMap/Box containers | **Deferred.** Struct-field recursion covers V1 library modules. | `S5-facet-walker-containers.md` | +| **S6** | Migration from FieldSpec/MetaSignature to SignatureSchema | **Subsumed by S1 → Option C.** No migration — full replacement. | `S6-migration-fieldspec-to-signatureschema.md` | +| **S7** | `#[derive(Augmentation)]` feasibility + `Augmented` phantom type | **Feasible.** `from_parts`/`into_parts` removed from Signature. `Augmented` is a clean type-level combinator. | `S7-augmentation-derive-feasibility.md` | +| **S8** | Facet flatten metadata behavior | **`field.is_flattened()` + `field.shape()` recurse.** Direct mapping to design pseudocode. | `S8-facet-flatten-metadata.md` | + +--- + +## 16. Typed vs Dynamic Path Summary + +**The typed path (what users and module authors interact with):** + +```rust +// User defines signature +#[derive(Signature, Clone)] +struct QA { ... } + +// User picks a module +let cot = ChainOfThought::::new(); +let result = cot.call(input).await?; +``` + +No factory. No registry. No DynModule. Pure typed Rust. + +**The dynamic path (what the optimizer's internals use when doing structural optimization):** + +```rust +// Inside the optimizer's own code, never user-facing: +let node = registry::create("chain_of_thought", &schema, config)?; +graph.replace_node("answer", node)?; +``` + +The factory exists so that the **optimizer** (an LM or search algorithm) can say "make me a ChainOfThought for this schema" without having the concrete Rust type `ChainOfThought` available. It's the bridge between "an optimizer proposes a strategy by name" and "a concrete module gets instantiated." + +Nobody hand-writes a factory. Each library module (ChainOfThought, ReAct, etc.) **auto-registers** its factory as a side effect of existing. The registration could be: + +```rust +// Inside the ChainOfThought module definition — library code, not user code +inventory::submit! { + StrategyRegistration { + name: "chain_of_thought", + factory: &ChainOfThoughtFactory, + } +} +``` + +Or it could be generated by whatever derive/macro defines the module. + +**The hierarchy of who touches what:** + +| Layer | Who interacts | What they see | +|---|---|---| +| Signature, Module, Predict | **Users** | Typed structs, `.call()`, field access | +| Augmentation derive | **Module authors** (library or advanced users) | 5-line struct with `#[derive(Augmentation)]` | +| Generic Signature derive | **Module authors** | `#[derive(Signature)]` with generics + flatten | +| SignatureSchema | **Nobody directly** — adapter uses it internally | Invisible | +| Facet walker + DynPredictor | **Nobody directly** — optimizer uses it internally | Invisible | +| StrategyFactory + registry | **Nobody directly** — structural optimizer uses it internally | Invisible | +| ProgramGraph | **Structural optimizer internals** OR advanced interop code | Rare, opt-in | + +Layers below the line are pure plumbing. They exist so the system can optimize itself. The user never imports a Factory, never calls a registry, never constructs a DynModule. diff --git a/docs/specs/modules/shapes.md b/docs/specs/modules/shapes.md new file mode 100644 index 00000000..71e25a88 --- /dev/null +++ b/docs/specs/modules/shapes.md @@ -0,0 +1,206 @@ +# DSRs Module System — Shaping Document + +**Selected shape:** F (Facet-native typed modules with dynamic graph escape hatch) + +--- + +## Frame + +### Problem + +- DSRs has typed signatures and a working Predict, but no module system — users cannot compose prompting strategies (ChainOfThought, ReAct, BestOfN) or write custom multi-step pipelines +- The current `LegacyPredict` / `MetaSignature` path is stringly typed and parallel to the typed path — two systems doing the same job, neither complete +- Macro-emitted static `FieldSpec` arrays duplicate what Facet already knows about types +- Optimizers require manual `Optimizable` trait impls with hand-written `named_parameters()` traversal +- No mechanism for signature augmentation — ChainOfThought cannot add `reasoning` to an arbitrary signature's output +- No path toward structural optimization (changing program topology, not just leaf parameters) + +### Outcome + +- Module authors can express all DSPy module patterns (signature extension, multi-signature orchestration, module wrapping, aggregation) as idiomatic typed Rust +- Users compose modules by swapping types, not rewriting code — `Predict` → `ChainOfThought` is a type change, everything else works +- Optimizers discover all tunable parameters automatically from program structure +- A dynamic graph layer exists for structural optimization and PyO3 interop, sharing the same adapter and prompt format as the typed path +- Facet is the single source of truth for type metadata — no parallel schema systems + +--- + +## Requirements (R) + +| ID | Requirement | Status | +|----|-------------|--------| +| **R0** | Users define signatures as typed Rust structs with `#[derive(Signature)]`, getting typed Input/Output types and full IDE support | Core goal | +| **R1** | Library-provided modules (ChainOfThought, ReAct, BestOfN, Refine, ProgramOfThought, MultiChainComparison) are generic over any compatible Signature | Core goal | +| **R2** | Module output includes augmented fields (e.g. `reasoning`) accessible via natural field access (`result.reasoning`, `result.answer`) without the user naming wrapper types | Must-have | +| **R3** | Module authoring (by library or advanced users) requires: define augmentation fields OR signature struct, implement forward logic — no traversal boilerplate, no manual schema construction | Must-have | +| **R4** | Optimizers discover all Predict leaves in an arbitrary module tree automatically — no `#[parameter]` annotations, no manual `named_parameters()` impl | Must-have | +| **R5** | All four DSPy augmentation patterns are expressible: signature extension, multi-signature orchestration, module wrapping, aggregation | Must-have | +| **R6** | Custom user types (enums, structs, nested) work in signatures via `#[BamlType]`, including inside augmented signatures | Must-have | +| **R7** | A dynamic program graph can be constructed, validated, mutated, and executed — nodes are module instances, edges are validated by field type compatibility | Must-have | +| **R8** | Typed modules and dynamic graph nodes produce identical prompts for the same logical signature — one adapter, one prompt format | Must-have | +| **R9** | Type metadata (field names, types, docs, constraints, ordering) is derived from Facet Shapes at runtime — not from macro-emitted static arrays | Must-have | +| **R10** | Parameter state (demos, instruction overrides) serializes to JSON and loads back into an existing typed module without serializing type structure | Must-have | +| **R11** | ReAct-style modules accept tools as plain Rust async functions with a builder API (`.tool("name", "desc", fn)`) | Must-have | +| **R12** | Module composition is type-safe: passing wrong input type to a module is a compile error | Must-have | +| **R13** | Augmentations compose: `(Reasoning, Confidence)` produces output with both `reasoning` and `confidence` fields, all accessible | Nice-to-have | +| **R14** | Dynamic graph nodes can be instantiated from a strategy registry by name + schema + config (e.g. `registry::create("react", schema, config)`) | Must-have | +| **R16** | The typed path is the default and primary experience — the dynamic/untyped path exists but is not required for normal use | Must-have | + +--- + +## Shape F: Facet-native typed modules with dynamic graph escape hatch + +### Parts + +| Part | Mechanism | Flag | +|------|-----------|:----:| +| **F1** | **Signature trait + derive macro** — `#[derive(Signature)]` on a struct with `#[input]`/`#[output]` fields generates `Input`/`Output` helper types, implements `Signature` trait. Supports generic type parameters and `#[flatten]` for composition. Doc comments become LM instructions/descriptions. | | +| **F2** | **SignatureSchema (Facet-derived, cached)** — `SignatureSchema::of::()` walks `S::Input` and `S::Output` Facet Shapes to produce an ordered flat field list with TypeIR, docs, constraints, and flatten paths. Cached in `OnceLock`. Used by adapter for prompt formatting/parsing AND by dynamic graph for edge validation. Replaces macro-emitted `FieldSpec` arrays. | | +| **F3** | **Augmentation derive + combinator** — `#[derive(Augmentation)]` on a small struct (e.g. `Reasoning { reasoning: String }`) generates: a wrapper type (`WithReasoning`) with `#[flatten]` on inner + `Deref` to inner, and the `Augmentation` trait impl. `Augmented` is a generic signature combinator (same input, wrapped output). Eliminates per-augmentation signature boilerplate. | | +| **F4** | **Module trait** — `trait Module { type Input; type Output; async fn forward(&self, input) -> Result }`. All prompting strategies implement this: `Predict`, `ChainOfThought`, `ReAct`, `BestOfN`, `Refine`, user-defined modules. This is the swapping/composition interface. | | +| **F5** | **Predict as leaf parameter** — `Predict` holds typed demos `Vec>`, optional instruction override, tools. Only thing that calls the LM. Marked with Facet attribute `dsrs::parameter` for automatic discovery. Implements both `Module` and `DynPredictor` (type-erased optimizer interface). | | +| **F6** | **Facet-powered parameter discovery** — A walker reflects over any `Facet` value, recurses through struct fields, yields `(dotted_path, &dyn DynPredictor)` for every value whose Shape carries `dsrs::parameter`. No manual traversal code. Replaces `#[derive(Optimizable)]` + `#[parameter]`. Container traversal (`Option`/`Vec`/`HashMap`/`Box`) is deferred (S5) — struct-field recursion covers all V1 library modules. | | +| **F7** | **Adapter building blocks** — ChatAdapter exposes public composable functions: `build_system()`, `format_input()`, `parse_sections()`, `parse_output()`. Modules that need fine-grained control (ReAct action loop) call these directly. Standard modules go through the high-level `format_system_message_typed::()` which calls building blocks internally. All operate on `SignatureSchema` (F2). | | +| **F8** | **DynPredictor vtable** — Type-erased interface for optimizer operations on a Predict leaf: get/set demos (as `Vec`), get/set instruction, get schema, `forward_untyped(BamlValue) -> BamlValue`. Obtained via shape-local accessor payload: `Predict` carries `PredictAccessorFns` as a typed Facet attribute, extracted at discovery time by the walker. Bridges typed Predict to untyped optimizer. | | +| **F9** | **DynModule + StrategyFactory** — `DynModule` is the dynamic equivalent of `Module` (BamlValue in/out, exposes internal predictors). `StrategyFactory` creates a `DynModule` from a `SignatureSchema` + config. Each module type (ChainOfThought, ReAct, etc.) registers a factory. Factories perform schema transformations (prepend reasoning, build action schema from tools, etc.) on `SignatureSchema` directly. | | +| **F10** | **ProgramGraph** — Dynamic graph of `Node` (holds `DynModule` + `SignatureSchema`) and `Edge` (from_node.field → to_node.field). Edges validated by TypeIR compatibility at insertion time. Supports `add_node`, `remove_node`, `replace_node`, `connect`, `insert_between`. Execution follows topological order, piping `BamlValue` between nodes. Typed modules can be projected into a graph (via F6 walker) and graph nodes can wrap typed modules internally. | | +| **F11** | **Library modules** — Concrete implementations of DSPy's module zoo: `ChainOfThought` (F3 augmentation + Predict), `ReAct` (two Predicts + tool loop + builder API), `BestOfN` (wraps any Module), `Refine` (BestOfN + feedback, scoped context mechanism TBD), `ProgramOfThought` (three ChainOfThought + code interpreter), `MultiChainComparison` (M sources + comparison Predict). Each is generic over Signature, implements Module, and is discoverable via F6. | ⚠️ | +| **F12** | **Generic Signature derive** — `#[derive(Signature)]` works on structs with generic type parameters (e.g. `ActionStep`) and `#[flatten]` fields. The generated `Input`/`Output` types carry the generic parameters through. Required for module authors who define custom multi-field signatures. Implementation path: generic forwarding in macro + path-aware runtime metadata bridge + path-based adapter format/parse (see S1). | | + +**Flag notes:** +- **F11 ⚠️**: ChainOfThought and BestOfN have concrete designs. Remaining unknowns: Refine's scoped context mechanism (S4 deferred — `tokio::task_local!` vs explicit parameter TBD when Refine is built), ReAct's tool builder API (concrete `ToolDyn` trait, action/extract loop wiring), and MultiChainComparison's attempt format (how M source outputs are aggregated into the comparison prompt). These require prototyping during implementation. + +--- + +## Fit Check (R × F) + +| Req | Requirement | Status | F | +|-----|-------------|--------|---| +| **R0** | Users define signatures as typed Rust structs with `#[derive(Signature)]`, getting typed Input/Output types and full IDE support | Core goal | ✅ | +| **R1** | Library-provided modules are generic over any compatible Signature | Core goal | ✅ | +| **R2** | Module output includes augmented fields accessible via natural field access without naming wrapper types | Must-have | ✅ | +| **R3** | Module authoring requires: define fields + implement forward — no traversal boilerplate, no manual schema construction | Must-have | ✅ | +| **R4** | Optimizers discover all Predict leaves automatically — no annotations, no manual traversal | Must-have | ✅ | +| **R5** | All four DSPy augmentation patterns are expressible | Must-have | ✅ | +| **R6** | Custom user types work in signatures, including inside augmented signatures | Must-have | ✅ | +| **R7** | Dynamic program graph can be constructed, validated, mutated, and executed | Must-have | ✅ | +| **R8** | Typed and dynamic paths produce identical prompts | Must-have | ✅ | +| **R9** | Type metadata derived from Facet Shapes, not macro-emitted static arrays | Must-have | ✅ | +| **R10** | Parameter state serializes/deserializes without serializing type structure | Must-have | ✅ | +| **R11** | ReAct accepts tools as plain async functions with builder API | Must-have | ✅ | +| **R12** | Module composition is type-safe | Must-have | ✅ | +| **R13** | Augmentations compose via tuples | Nice-to-have | ✅ | +| **R14** | Dynamic graph nodes instantiated from registry by name + schema + config | Must-have | ✅ | +| **R16** | Typed path is default, dynamic is opt-in | Must-have | ✅ | + +**Notes:** +- R2 satisfied by `Deref` coercion on wrapper types — `result.reasoning` is a direct field, `result.answer` resolves via Deref to inner type. S3 confirmed: auto-deref works through multiple layers for field reads and method calls. Pattern matching requires explicit layer-by-layer destructuring (acceptable — documented limitation). +- R4 satisfied by Facet walker (F6) using shape-local accessor payloads (S2: Mechanism A). `#[derive(Facet)]` on the module struct is the only requirement. V1 walker recurses through struct fields only; container traversal deferred (S5). +- R8 satisfied by both paths using `SignatureSchema` (F2) → same adapter building blocks (F7) → same prompt format. + +--- + +## Layers (how parts compose) + +``` +Layer 0: Types + Rust types with Facet + BamlType derives + Source of truth. Never serialized. Lives in the binary. + Parts: (foundation for everything) + +Layer 1: Typed Modules + Signature trait (F1) + SignatureSchema (F2) + Augmentation (F3) + + Module trait (F4) + Predict (F5) + Library modules (F11) + Compile-time checked. IDE-supported. 90% of programs live here. + Parts: F1, F2, F3, F4, F5, F11, F12 + +Layer 2: Optimization Bridge + Facet parameter discovery (F6) + DynPredictor vtable (F8) + + Adapter building blocks (F7) + Minimal type erasure for optimizer access to typed leaves. + Parts: F6, F7, F8 + +Layer 3: Dynamic Graph + DynModule + StrategyFactory (F9) + ProgramGraph (F10) + Opt-in for structural optimization and PyO3 interop. + Parts: F9, F10 +``` + +Each layer only exists if needed. A simple `Predict::::new().call(input)` touches Layers 0-1 and the adapter from Layer 2. The graph layer is never instantiated unless structural optimization or PyO3 is in play. + +--- + +## Spikes (Resolved) + +All spikes have been investigated and resolved. Full findings in `spikes/S{n}-*.md`. + +| # | Question | Decision | Spike doc | +|---|----------|----------|-----------| +| **S1** | Can `#[derive(Signature)]` handle generic type parameters with `#[flatten]` fields? | **Option C: full replacement.** Build `SignatureSchema` from Facet, replace `FieldSpec` everywhere, delete the old system. No incremental migration. | `S1-generic-signature-derive.md` | +| **S2** | How does the Facet walker obtain a usable optimizer handle from a discovered Predict? | **Mechanism A**: shape-local accessor payload (`dsrs::parameter` + fn-pointer `PredictAccessorFns`). Reuses existing `WithAdapterFns` typed-attr pattern. | `S2-dynpredictor-handle-discovery.md` | +| **S3** | Does Rust auto-Deref chain resolve field access through nested augmentation wrappers? | **Yes for reads/methods**, no for pattern matching (don't care). `Deref`-only unless `DerefMut` is proven necessary. | `S3-augmentation-deref-composition.md` | +| **S4** | What scoped-context mechanism for Refine's hint injection? | **Deferred.** Mechanism chosen when Refine is built. Findings preserved in spike doc. | `S4-refine-scoped-context.md` | +| **S5** | How does the Facet walker handle Option/Vec/HashMap/Box containers? | **Deferred.** Struct-field recursion covers all V1 library modules. Container traversal when a concrete use case requires it. | `S5-facet-walker-containers.md` | +| **S6** | Migration path from FieldSpec/MetaSignature to Facet-derived SignatureSchema? | **Subsumed by S1 → Option C.** No migration — full replacement. | `S6-migration-fieldspec-to-signatureschema.md` | +| **S7** | Can `#[derive(Augmentation)]` generate a generic wrapper from a non-generic struct? What about the `Augmented` phantom type? | **Yes, feasible.** All three derives handle generics. `from_parts`/`into_parts` removed from `Signature` trait — `Augmented` becomes a clean type-level combinator. | `S7-augmentation-derive-feasibility.md` | +| **S8** | How does Facet flatten manifest in Shape metadata? | **`field.is_flattened()` flag check + `field.shape()` recurse.** Facet ships `fields_for_serialize()` as reference. Direct mapping to design pseudocode. | `S8-facet-flatten-metadata.md` | + +--- + +## Fit Check — F × R (Parts as rows, Requirements as columns) + +✅ = this part directly satisfies this requirement. Blank = does not. + +| Part | R0 | R1 | R2 | R3 | R4 | R5 | R6 | R7 | R8 | R9 | R10 | R11 | R12 | R13 | R14 | R16 | +|------|----|----|----|----|----|----|----|----|----|----|-----|-----|-----|-----|-----|-----| +| **F1** Signature derive | ✅ | | | | | | ✅ | | | | | | ✅ | | | ✅ | +| **F2** SignatureSchema | | | | ✅ | | | ✅ | | ✅ | ✅ | | | | | | | +| **F3** Augmentation | | ✅ | ✅ | ✅ | | ✅ | ✅ | | | | | | ✅ | ✅ | | | +| **F4** Module trait | | ✅ | | ✅ | | ✅ | | | | | | | ✅ | | | ✅ | +| **F5** Predict leaf | | | | | | | | | | | ✅ | | | | | ✅ | +| **F6** Facet discovery | | | | ✅ | ✅ | | | | | ✅ | | | | | | | +| **F7** Adapter blocks | | | | ✅ | | ✅ | | | ✅ | ✅ | | ✅ | | | | | +| **F8** DynPredictor | | | | | ✅ | | | ✅ | | | ✅ | | | | | | +| **F9** DynModule+Factory | | | | | | | | ✅ | ✅ | | | | | | ✅ | | +| **F10** ProgramGraph | | | | | | | | ✅ | ✅ | | | | | | ✅ | | +| **F11** Library modules | | ✅ | ✅ | | | ✅ | ✅ | | | | | ✅ | ✅ | | | ✅ | +| **F12** Generic Sig derive | ✅ | ✅ | | ✅ | | ✅ | ✅ | | | | | | ✅ | | | ✅ | + +--- + +### Coverage analysis + +**Every R is covered by at least one part.** Reading the columns: + +| Req | Description | Satisfied by | +|-----|-------------|-------------| +| R0 | Typed signature derive | F1, F12 | +| R1 | Modules generic over Signature | F3, F4, F11, F12 | +| R2 | Augmented fields accessible naturally | F3, F11 | +| R3 | No traversal boilerplate, no manual schema | F2, F3, F4, F6, F7, F12 | +| R4 | Automatic optimizer discovery | F6, F8 | +| R5 | All four augmentation patterns | F3, F4, F5 (implicitly), F7, F11, F12 | +| R6 | Custom types in signatures | F1, F2, F3, F11, F12 | +| R7 | Dynamic program graph | F8, F9, F10 | +| R8 | Typed and dynamic produce identical prompts | F2, F7, F9, F10 | +| R9 | Metadata from Facet not static arrays | F2, F6, F7 | +| R10 | Parameter state serialization | F5, F8 | +| R11 | ReAct tool builder API | F7, F11 | +| R12 | Composition is type-safe | F1, F3, F4, F11, F12 | +| R13 | Augmentations compose via tuples | F3 | +| R14 | Dynamic nodes from registry | F9, F10 | +| R16 | Typed path is default | F1, F4, F5, F11, F12 | + +**Observations:** + +**R13 (augmentation composition) has the thinnest coverage** — only F3. S3 confirmed auto-deref works for reads/methods, so the risk is mitigated. Pattern matching through nested wrappers requires explicit destructuring — acceptable for a Nice-to-have. + +**R4 (automatic discovery) depends on F6 + F8 together.** F6 finds the values, F8 makes them operable. S2 resolved the handle mechanism (shape-local accessor payload). Container traversal deferred (S5) — struct-field recursion is sufficient for V1. + +**R7 (dynamic graph) is the heaviest requirement** — needs F8, F9, AND F10. All three are Layer 3. This is expected — it's the most complex capability. + +**F3 (Augmentation) is the most load-bearing part** — covers 8 requirements. S3 confirmed the core deref ergonomics work. Flatten round-trips depend on `SignatureSchema` path-aware adapter (S1/C). + +**F7 (Adapter building blocks) quietly covers 5 requirements.** It's the mechanism that makes R8 (identical prompts) and R11 (ReAct tools) work. Exposing the adapter's internals as composable functions is less glamorous than the type system work but equally essential. + +**F11 (Library modules) is flagged ⚠️ and covers 6 requirements.** The flag is narrowed: ChainOfThought, BestOfN, and Refine have concrete designs. ReAct's tool builder and MultiChainComparison's aggregation format remain as prototyping work during implementation. We can't call the system done without at least ChainOfThought + ReAct + BestOfN working end-to-end. diff --git a/docs/specs/modules/skills/breadboarding/SKILL.md b/docs/specs/modules/skills/breadboarding/SKILL.md new file mode 100644 index 00000000..ba34fa1b --- /dev/null +++ b/docs/specs/modules/skills/breadboarding/SKILL.md @@ -0,0 +1,1622 @@ +--- +name: breadboarding +description: Transform a workflow description into affordance tables showing UI and Code affordances with their wiring. Use to map existing systems or design new ones from shaped parts. +--- + +# Breadboarding + +Breadboarding transforms a workflow description into a complete map of affordances and their relationships. The output is always a set of tables showing numbered UI and Code affordances with their Wires Out and Returns To relationships. The tables are the truth. Mermaid diagrams are optional visualizations for humans. + +--- + +## Use Cases + +Breadboarding serves two functions: + +### 1. Mapping an Existing System + +You don't understand how an existing system works in its concrete details. You have a workflow you're trying to understand — explaining how something happens or why something doesn't happen. + +**Input:** +- Code repo(s) to analyze +- Workflow description (always from the perspective of an operator trying to make an effect happen — through UI or as a caller) + +**Output:** +- UI Affordances table +- Code Affordances table +- (Optional) Mermaid visualization + +**Note:** If the workflow spans multiple applications (frontend + backend), create ONE breadboard that tells the full story. Label places to show which system they belong to. + +### 2. Designing from Shaped Parts + +You have a new system sketched as an assembly of parts (mechanisms) per shaping. You need to detail out the concrete mechanism and show how those parts interact as a system. + +**Input:** +- Parts list (mechanisms from shaping) +- The R (requirement/outcome) the parts are meant to achieve +- Existing system (optional) — if the new parts must interoperate with existing code + +**Output:** +- UI Affordances table +- Code Affordances table +- (Optional) Mermaid visualization + +### Mixtures + +Often you have both: an existing system that must remain as-is, plus new pieces or changes defined in a shape. In this case, breadboard both together — the existing affordances and the new ones — showing how they connect. + +--- + +## Core Concepts + +### Places + +A Place is a **bounded context of interaction**. While you're in a Place: +- You have a specific set of affordances available to you +- You **cannot** interact with affordances outside that boundary +- You must take an action to leave + +**Place is perceptual, not technical.** It's not about URLs or components — it's about what the user experiences as their current context. A Place is "where you are" in terms of what you can do right now. + +#### The Blocking Test + +The simplest test for whether something is a different Place: **Can you interact with what's behind?** + +| Answer | Meaning | +|--------|---------| +| **No** | You're in a different Place | +| **Yes** | Same Place, with local state changes | + +#### Examples + +| UI Element | Blocking? | Place? | Why | +|------------|-----------|--------|-----| +| Modal | Yes | Yes | Can't interact with page behind | +| Confirmation popover | Yes | Yes | Must respond before returning (limit case of modal) | +| Edit mode (whole screen transforms) | Yes | Yes | All affordances changed | +| Checkbox reveals extra fields | No | No | Surroundings unchanged | +| Dropdown menu | No | No | Can click away, non-blocking | +| Tooltip | No | No | Informational, non-blocking | + +#### Local State vs Place Navigation + +When a control changes state, ask: did *everything* change, or just a subset while the surroundings stayed the same? + +| Type | What happens | How to model | +|------|--------------|--------------| +| **Local state** | Subset of UI changes, surroundings unchanged | Same Place, conditional N → dependent Us | +| **Place navigation** | Entire screen transforms, or blocking overlay | Different Places | + +#### Mode-Based Places + +When a mode (like "edit mode") transforms the entire screen — different buttons, different affordances everywhere — model as separate Places: + +``` +PLACE: CMS Page (Read Mode) +PLACE: CMS Page (Edit Mode) +``` + +The state flag (e.g., `editMode$`) that switches between them is a **navigation mechanism**, not a data store. Don't include it as an S in either Place. + +#### Three Questions for Any Control + +For any UI affordance, ask: +1. Where did I come from to see this? +2. Where am I now? +3. Where do I go if I act on it? + +If the answer to #3 is "everything changes" or "I can't interact with what's behind until I respond," that's navigation to a different Place. + +#### Labeling Conventions + +| Pattern | Use | +|---------|-----| +| `PLACE: Page Name` | Standard page/route | +| `PLACE: Page Name (Mode)` | Mode-based variant of a page | +| `PLACE: Modal Name` | Modal dialog | +| `PLACE: Backend` | API/database boundary | + +When spanning multiple systems, label with the system: `PLACE: Checkout Page (frontend)`, `PLACE: Payment API (backend)`. + +### Place IDs + +Places are first-class elements in the data model. Each Place gets an ID: + +| # | Place | Description | +|---|-------|-------------| +| P1 | CMS Page (Read Mode) | View-only state | +| P2 | CMS Page (Edit Mode) | Editing state with page-level controls | +| P2.1 | widget-grid (letters) | Subplace: letter editing widget within P2 | +| P3 | Letter Form Modal | Form for adding/editing letters | +| P4 | Backend | API resolvers and database | + +Place IDs enable: +- **Explicit navigation wiring** — wire `→ P2` instead of to an affordance inside +- **Containment tracking** — each affordance declares which Place it belongs to +- **Consistent Mermaid subgraphs** — subgraph ID matches Place ID + +### Place References + +When a nested place has lots of internal affordances and would clutter the parent, you can **detach** it: + +1. Put a **reference node** in the parent place using underscore prefix: `_letter-browser` +2. Define the full place separately with all its internals +3. Wire from the reference to the place: `_letter-browser --> letter-browser` + +The reference is a **UI affordance** — it represents "this widget/component renders here" in the parent context. + +```mermaid +flowchart TB +subgraph P1["P1: CMS Page (Read Mode)"] + U1["U1: Edit button"] + U_LB["_letter-browser"] +end + +subgraph letterBrowser["letter-browser"] + U10["U10: Search input"] + U11["U11: Letter list"] + N40["N40: performSearch()"] +end + +U_LB --> letterBrowser +``` + +In affordance tables, list the reference as a UI affordance: + +| # | Affordance | Control | Wires Out | +|---|------------|---------|-----------| +| U1 | Edit button | click | → N1 | +| _letter-browser | Widget reference | — | → P3 | + +Style place references with a dashed border to distinguish them: +``` +classDef placeRef fill:#ffb6c1,stroke:#d87093,stroke-width:2px,stroke-dasharray:5 5 +class U_LB placeRef +``` + +### Modes as Places + +When a component has distinct modes (read vs edit, viewing vs editing, collapsed vs expanded), model them as **separate places** — they're different perceptual states for the user. + +If one mode includes everything from another plus more, show this with a **place reference** inside the extended place: + +``` +P3: letter-browser (Read) — base state +P4: letter-browser (Edit) — contains _letter-browser (Read) + new affordances +``` + +The reference shows composition: "everything in P3 appears here, plus these additions." + +```mermaid +flowchart TB +subgraph P3["P3: letter-browser (Read)"] + U10["U10: Search input"] + U11["U11: Letter list"] +end + +subgraph P4["P4: letter-browser (Edit)"] + U_P3["_letter-browser (Read)"] + U3["U3: Add button"] + U4["U4: Edit button"] +end + +U_P3 --> P3 +``` + +In affordance tables for P4, the reference shows inheritance: + +| # | Affordance | Control | Wires Out | Notes | +|---|------------|---------|-----------|-------| +| _letter-browser (Read) | Inherits all of P3 | — | → P3 | | +| U3 | Add button | click | → N3 | NEW | +| U4 | Edit button | click | → N4 | NEW | + +### Subplaces + +A **subplace** is a defined subset of a Place — a contained area that groups related affordances. Use subplaces when: +- A Place contains multiple distinct widgets or sections +- You're detailing one part of a larger Place +- You want to show what's in scope vs out of scope + +**Notation:** Use hierarchical IDs — `P2.1`, `P2.2`, etc. for subplaces of P2. + +``` +| # | Place | Description | +|---|-------|-------------| +| P2 | Dashboard | Main dashboard page | +| P2.1 | Sales widget | Subplace: sales metrics | +| P2.2 | Activity feed | Subplace: recent activity | +``` + +In affordance tables, use the subplace ID to show containment: + +``` +| U3 | P2.1 | sales-widget | "Refresh" button | click | → N4 | — | +| U7 | P2.2 | activity-feed | activity list | render | — | — | +``` + +**In Mermaid:** Nest the subplace subgraph inside the parent. Use the same background color (no distinct fill) — the subplace is part of the parent, not a separate Place: + +```mermaid +flowchart TB +subgraph P2["P2: Dashboard"] + subgraph P2_1["P2.1: Sales widget"] + U3["U3: Refresh button"] + end + subgraph P2_2["P2.2: Activity feed"] + U7["U7: activity list"] + end + otherContent[["... other dashboard content ..."]] +end +``` + +**Placeholder for out-of-scope content:** When detailing one subplace, add a placeholder sibling to show there's more on the page: + +``` +otherContent[["... other page content ..."]] +``` + +This tells readers: "we're zooming in on P2.1, but P2 contains more that we're not detailing." + +### Containment vs Wiring + +These are two different relationships in the data model: + +| Relationship | Meaning | Where Captured | +|--------------|---------|----------------| +| **Containment** | Affordance belongs to / lives in a Place | **Place column** (set membership) | +| **Wiring** | Affordance triggers / calls something | **Wires Out column** (control flow) | + +**Containment** is set membership: `U1 ∈ P1` means U1 is a member of Place P1. Every affordance belongs to exactly one Place. + +**Wiring** is control flow: `U1 → N1` means U1 triggers N1. An affordance can wire to anything — other affordances or Places. + +The Place column answers: "Where does this affordance live?" +The Wires Out column answers: "What does this affordance trigger?" + +### Navigation Wiring + +When an affordance causes navigation (user "goes" somewhere), wire to the **Place itself**, not to an affordance inside: + +``` +✅ N1 Wires Out: → P2 (navigate to Edit Mode) +❌ N1 Wires Out: → U3 (wiring to affordance inside P2) +``` + +This makes navigation explicit in the tables. The Place is the destination; specific affordances inside become available once you arrive. + +In Mermaid, this becomes: +``` +N1 --> P2 +``` + +The subgraph ID matches the Place ID, so the wire connects to the Place boundary. + +### Affordances +Things you can act upon: +- **UI affordances (U)**: inputs, buttons, displayed elements, scroll regions +- **Code affordances (N)**: methods, subscriptions, data stores, framework mechanisms + +### Wiring +How affordances connect to each other: + +**Wires Out** — What an affordance triggers or calls (control flow): +- Call wires: one affordance calls another +- Write wires: code writes to a data store +- Navigation wires: routing to a different place + +**Returns To** — Where an affordance's output flows (data flow): +- Return wires: function returns value to its caller +- Read wires: data store is read by another affordance + +This separation makes data flow explicit. Wires Out show control flow (what triggers what). Returns To show data flow (where output goes). + +--- + +## The Output: Affordance Tables + +The tables are the truth. Every breadboard produces these: + +### Places Table + +| # | Place | Description | +|---|-------|-------------| +| P1 | Search Page | Main search interface | +| P2 | Detail Page | Individual result view | + +### UI Affordances Table + +| # | Place | Component | Affordance | Control | Wires Out | Returns To | +|---|-------|-----------|------------|---------|-----------|------------| +| U1 | P1 | search-detail | search input | type | → N1 | — | +| U2 | P1 | search-detail | loading spinner | render | — | — | +| U3 | P1 | search-detail | results list | render | — | — | +| U4 | P1 | search-detail | result row | click | → P2 | — | + +### Code Affordances Table + +| # | Place | Component | Affordance | Control | Wires Out | Returns To | +|---|-------|-----------|------------|---------|-----------|------------| +| N1 | P1 | search-detail | `activeQuery.next()` | call | → N2 | — | +| N2 | P1 | search-detail | `activeQuery` subscription | observe | → N3 | — | +| N3 | P1 | search-detail | `performSearch()` | call | → N4, → N5, → N6 | — | +| N4 | P1 | search.service | `searchOneCategory()` | call | → N7 | → N3 | +| N5 | P1 | search-detail | `loading` | write | store | → U2 | +| N6 | P1 | search-detail | `results` | write | store | → U3 | +| N7 | P1 | typesense.service | `rawSearch()` | call | — | → N4 | + +### Data Stores Table + +| # | Place | Store | Description | +|---|-------|-------|-------------| +| S1 | P1 | `results` | Array of search results | +| S2 | P1 | `loading` | Boolean loading state | + +### Column Definitions + +| Column | Description | +|--------|-------------| +| **#** | Unique ID (P1, P2... for Places; U1, U2... for UI; N1, N2... for Code; S1, S2... for Stores) | +| **Place** | Which Place this affordance belongs to (containment) | +| **Component** | Which component/service owns this | +| **Affordance** | The specific thing you can act upon | +| **Control** | The triggering event: click, type, call, observe, write, render | +| **Wires Out** | What this triggers: `→ N4`, `→ P2` (control flow, including navigation) | +| **Returns To** | Where output flows: `→ N3` or `→ U2, U3` (data flow) | + +--- + +## Procedures + +### For Mapping an Existing System + +See **Example A** below for a complete worked example. + +**Step 1: Identify the flow to analyze** + +Pick a specific user journey. Always frame it as an operator trying to do something: +- "Land on /search, type query, scroll for more, click result" +- "Call the payment API with a card token, expect a charge to be created" + +**Step 2: List all places involved** + +Walk through the journey and identify each distinct place the user visits or system boundary crossed. + +**Step 3: Trace through the code to find components** + +Starting from the entry point (route, API endpoint), trace through the code to find every component touched by that flow. + +**Step 4: For each component, list its affordances** + +Read the code. Identify: +- UI: What can the user see and interact with? +- Code: What methods, subscriptions, stores are involved? + +**Step 5: Name the actual thing, not an abstraction** + +If you write "DATABASE", stop. What's the actual method? (`userRepo.save()`). Every affordance name must be something real you can point to in the code. + +**Step 6: Fill in Control column** + +For each affordance, what triggers it? (click, type, call, observe, write, render) + +**Step 7: Fill in Wires Out** + +For each affordance, what does it trigger? Read the code — what does this method call? What does this button's handler invoke? + +**Step 8: Fill in Returns To** + +For each affordance, where does its output flow? +- Functions that return values → list the callers that receive the return +- Data stores → list the affordances that read from them +- No meaningful output → use `—` + +**Step 9: Add data stores as affordances** + +When code writes to a property that is later read by another affordance, add that property as a Code affordance with control type `write`. + +**Step 10: Add framework mechanisms as affordances** + +Include things like `cdr.detectChanges()` that bridge between code and UI rendering. These show how state changes actually reach the UI. + +**Step 11: Verify against the code** + +Read the code again. Confirm every affordance exists and the wiring matches reality. + +--- + +### For Designing from Shaped Parts + +See **Example B** below for a complete worked example including slicing. + +**Step 1: List each part from the shape** + +Take each mechanism/part identified in shaping and write it down. + +**Step 2: Translate parts into affordances** + +For each part, identify: +- What UI affordances does this part require? +- What Code affordances implement this part? + +**Step 3: Verify every U has a supporting N** + +For each UI affordance, check: what Code affordance provides its data or controls its rendering? If none exists, add the missing N. + +**Step 4: Classify places as existing or new** + +For each UI affordance, determine whether it lives in: +- An existing place being modified +- A new place being created + +**Step 5: Wire the affordances** + +Fill in Wires Out and Returns To for each affordance. Trace through the intended behavior — what calls what? What returns where? + +**Step 6: Connect to existing system (if applicable)** + +If there's an existing codebase: +- Identify the existing affordances the new ones must connect to +- Add those existing affordances to your tables +- Wire the new affordances to them + +**Step 7: Check for completeness** + +- Every U should have an N that feeds it +- Every N should have either Wires Out or Returns To (or both) +- Handlers → should have Wires Out +- Queries → should have Returns To +- Data stores → should have Returns To + +**Step 8: Treat user-visible outputs as Us** + +Anything the user sees (including emails, notifications) is a UI affordance and needs an N wiring to it. + +--- + +## Key Principles + +### Never use memory — always check the data + +When tracing a flow backwards, don't follow the path you remember. Scan the Wires Out column for ALL affordances that wire to your target. + +When filling in the tables, read each row systematically. Don't rely on what you think you know. + +The tables are the source of truth. Your memory is unreliable. + +### Every affordance name must exist (when mapping) + +When mapping existing code, never invent abstractions. Every name must point to something real in the codebase. + +### Mechanisms aren't affordances + +An affordance is something you can **act upon** that has meaningful identity in the system. Several things look like affordances but are actually just implementation mechanisms: + +| Type | Example | Why it's not an affordance | +|------|---------|---------------------------| +| Visual containers | `modal-frame wrapper` | You can't act on a wrapper — it's just a Place boundary | +| Internal transforms | `letterDataTransform()` | Implementation detail of the caller — not separately actionable | +| Navigation mechanisms | `modalService.open()` | Just the "how" of getting to a Place — wire to the destination directly | + +**These aren't always obvious on first draft.** When reviewing your affordance tables, double-check each Code affordance and ask: + +> "Is this actually an affordance, or is it just detailing the mechanism for how something happens?" + +If it's just the "how" — skip it and wire directly to the destination or outcome. + +**Examples:** + +``` +❌ N8 --> N22 --> P3 (N22 is modalService.open — just mechanism) +✅ N8 --> P3 (handler navigates to modal) + +❌ N6 --> N20 --> S2 (N20 is data transform — internal to N6) +✅ N6 --> S2 (callback writes to store) + +❌ U7: modal-frame (wrapper — just the boundary of P3) +✅ U8: Save button (actionable) +``` + +The handler navigates to P3. The callback writes to the store. The modal IS P3. The mechanisms are implicit. + +### Two flows: Navigation and Data + +A breadboard captures two distinct flows: + +| Flow | What it tracks | Wiring | +|------|----------------|--------| +| **Navigation** | Movement from Place to Place | Wires Out → Places | +| **Data** | How state populates displays | Returns To → Us | + +These are orthogonal. You can have navigation without data changes, and data changes without navigation. + +**When reviewing a breadboard, trace both flows:** + +1. **Navigation flow:** Can you follow the user's journey from Place to Place? +2. **Data flow:** For every U that displays data, can you trace where that data comes from? + +### Every U that displays data needs a source + +A UI affordance that displays data must have something feeding it — either a data store (S) or a code affordance (N) that returns data. + +``` +❌ U6: letter list (no incoming wire — where does the data come from?) +✅ S1 -.-> U6 (store feeds the display) +✅ N4 -.-> U6 (query result feeds the display) +``` + +If a display U has no data source wiring into it, either: +1. The source is missing from the breadboard +2. The U isn't real + +This is easy to miss when focused on navigation. Always ask: "This U shows data — where does that data come from?" + +### Every N must connect + +If a Code affordance has no Wires Out AND no Returns To, something is wrong: +- Handlers → should have Wires Out (what they call or write) +- Queries → should have Returns To (who receives their return value) +- Data stores → should have Returns To (which affordances read them) + +### Side effects need stores + +An N that appears to wire nowhere is suspicious. If it has **side effects outside the system boundary** (browser URL, localStorage, external API, analytics), add a **store node** to represent that external state: + +``` +❌ N41: updateUrl() (wires to... nothing?) +✅ N41: updateUrl() → S15 (wires to Browser URL store) +``` + +This makes the data flow explicit. The store can also have return wires showing how external state flows back in: + +```mermaid +flowchart TB +N42["N42: performSearch()"] --> N41["N41: updateUrl()"] +N41 --> S15["S15: Browser URL (?q=)"] +S15 -.->|back button / init| N40["N40: activeQuery$"] +``` + +Common external stores to model: +- `Browser URL` — query params, hash fragments +- `localStorage` / `sessionStorage` — persisted client state +- `Clipboard` — copy/paste operations +- `Browser History` — navigation state + +### Separate control flow from data flow + +Wires Out = control flow (what triggers what) +Returns To = data flow (where output goes) + +This separation makes the system's behavior explicit. + +### Show navigation inline, not as loops + +Routing is a generic mechanism every page uses. Instead of drawing all navigation through a central Router affordance, show `Router navigate()` inline where it happens and wire directly to the destination place. + +### Place stores where they enable behavior, not where they're written + +A data store belongs in the Place where its data is *consumed* to enable some effect — not where it's produced. Writes from other Places are "reaching into" that Place's state. + +To determine where a store belongs: +1. **Trace read/write relationships** — Who writes? Who reads? +2. **The readers determine placement** — that's where behavior is enabled +3. **If only one Place reads**, the store goes inside that Place + +Example: A `changedPosts` array is written by a Modal (when user confirms changes) but read by a PAGE_SAVE handler (when user clicks Save). The store belongs with the PAGE_SAVE handler — that's where it enables the persistence operation. + +### Only extract to shared areas when truly shared + +Before putting a store in a separate DATA STORES section, verify it's actually read by multiple Places. If it only enables behavior in one Place, it belongs inside that Place. + +### Nest stores in the subcomponent that reads them + +Within a Place, put stores in the subcomponent where they enable behavior. If a store is read by a specific handler, put it in that handler's component — not floating at the Place level. + +### Backend is a Place + +The database and resolvers aren't floating infrastructure — they're a Place with their own affordances. Database tables (S) belong inside the Backend Place alongside the resolvers (N) that read and write them. + +--- + +## Catalog of Parts and Relationships + +This section provides a complete reference of everything that can appear in a breadboard. + +### Elements + +| Element | ID Pattern | What It Is | What Qualifies | +|---------|------------|------------|----------------| +| **Place** | P1, P2, P3... | A bounded context of interaction | Blocking test: can't interact with what's behind | +| **Subplace** | P2.1, P2.2... | A defined subset within a Place | Groups related affordances within a larger Place | +| **Place Reference** | _PlaceName | UI affordance pointing to a detached place | Complex nested place defined separately | +| **UI Affordance** | U1, U2, U3... | Something the user can see or interact with | Inputs, buttons, displays, scroll regions | +| **Code Affordance** | N1, N2, N3... | Something in code you can act upon | Methods, subscriptions, handlers, framework mechanisms | +| **Data Store** | S1, S2, S3... | State that persists and is read/written | Properties, arrays, observables that hold data | +| **Chunk** | — | A collapsed subsystem | One wire in, one wire out, many internals | +| **Placeholder** | — | Out-of-scope content marker | Shows context without detailing | + +### Relationships + +| Relationship | Syntax | Meaning | Example | +|--------------|--------|---------|---------| +| **Containment** | Place column | Affordance belongs to Place | `U3` in Place `P2.1` | +| **Wires Out** | `→ X` | Control flow: triggers/calls | `→ N4`, `→ P2` | +| **Returns To** | `→ X` (in Returns To column) | Data flow: output goes to | `→ U6`, `→ N3` | +| **Abbreviated flow** | `\|label\|` | Intermediate steps omitted | `S4 -.-> \|view query\| U6` | +| **Parent-child** | Hierarchical ID | Subplace belongs to Place | P2.1 is child of P2 | + +### Containment vs Wiring + +| Relationship | Meaning | Where Captured | +|--------------|---------|----------------| +| **Containment** | Affordance belongs to / lives in a Place | Place column (set membership) | +| **Wiring** | Affordance triggers / calls something | Wires Out column (control flow) | + +Containment is set membership: `U1 ∈ P1` means U1 is a member of Place P1. +Wiring is control flow: `U1 → N1` means U1 triggers N1. + +### What Qualifies as Each Element + +**Place (P):** +- Passes the blocking test — can't interact with what's behind +- Examples: modal, edit mode (whole screen transforms), route/page +- Not: dropdown, tooltip, checkbox revealing fields + +**Place Reference (_PlaceName):** +- A UI affordance that represents a detached place +- Use when a nested place has many affordances and would clutter the parent +- Examples: `_letter-browser`, `_user-profile-widget` +- Wires to the full place definition: `_letter-browser --> P3` + +**UI Affordance (U):** +- User can see it or interact with it +- Examples: button, input, list, spinner, displayed text +- Not: wrapper elements, layout containers + +**Code Affordance (N):** +- Has meaningful identity — you can point to it in code +- Examples: `handleSubmit()`, `query$ subscription`, `detectChanges()` +- Not: internal transforms, navigation mechanisms (see below) + +**Data Store (S):** +- State that is written and read +- Examples: `results` array, `loading` boolean, `changedPosts` list +- External stores: `Browser URL`, `localStorage`, `Clipboard` — represent state outside the app boundary +- Not: config that's set once and never changes (consider as config affordance) + +### Verification Checks + +| Check | Question | If No... | +|-------|----------|----------| +| **Every U that displays data** | Does it have an incoming wire (via Wires Out or Returns To)? | Add the data source | +| **Every N** | Does it have Wires Out or Returns To (or both)? | Investigate — may be dead code or missing wiring | +| **Every S** | Does something read from it (Returns To)? | Investigate — may be unused | +| **Navigation mechanisms** | Is this N just the "how" of getting somewhere? | Wire directly to Place instead | +| **N with side effects** | Does this N affect external state (URL, storage, clipboard)? | Add a store for the external state | + +--- + +## Chunking + +Chunking collapses a subsystem into a single node in the main diagram, with details shown separately. Use chunking to manage complexity when a section of the breadboard has: + +- **One wire in** (single entry point) +- **One wire out** (single output) +- **Lots of internals** between them + +### When to Chunk + +Look for sections where tracing the wiring reveals a "pinch point" — many affordances that funnel through a single input and single output. These are natural boundaries for chunking. + +Example: A `dynamic-form` component receives a form definition, renders many fields (U7a-U7k), validates on change (N26), and emits a single `valid$` signal. In the main diagram, this becomes: + +``` +N24 -->|formDefinition| dynamicForm +dynamicForm -.->|valid$| U8 +``` + +### How to Chunk + +1. **In the main diagram**, replace the subsystem with a single stadium-shaped node: + +``` +dynamicForm[["CHUNK: dynamic-form"]] +``` + +2. **Wire to/from the chunk** using the boundary signals: + +``` +N24 -->|formDefinition| dynamicForm +dynamicForm -.->|valid$| U8 +``` + +3. **Create a separate chunk diagram** showing the internals with boundary markers: + +```mermaid +flowchart TB + input([formDefinition]) + output(["valid$"]) + + subgraph chunk["dynamic-form internals"] + N25["N25: generateFormConfig()"] + U7a["U7a: field"] + N26["N26: form value changes"] + N27["N27: valid$ emission"] + end + + input --> N25 + N25 --> U7a + U7a --> N26 + N26 --> N27 + N27 --> output + + classDef boundary fill:#b3e5fc,stroke:#0288d1,stroke-dasharray:5 5 + class input,output boundary +``` + +4. **Style chunks distinctly** in the main diagram: + +``` +classDef chunk fill:#b3e5fc,stroke:#0288d1,color:#000,stroke-width:2px +class dynamicForm chunk +``` + +### Chunk Color Convention + +| Type | Color | Hex | +|------|-------|-----| +| Chunk node (main diagram) | Light blue | `#b3e5fc` | +| Boundary markers (chunk diagram) | Light blue, dashed | `#b3e5fc` with `stroke-dasharray:5 5` | + +### Benefits + +- **Main diagram stays readable** — complex subsystems become single nodes +- **Detail preserved** — chunk diagrams show the internals when needed +- **Natural boundaries** — chunks often map to reusable components + +--- + +## Visualization (Mermaid) + +The tables are the truth. Mermaid diagrams are optional visualizations for humans. + +### Basic Structure + +```mermaid +flowchart TB + U1["U1: search input"] --> N1["N1: activeQuery.next()"] + N1 --> N2["N2: subscription"] + N2 --> N3["N3: performSearch"] + N3 --> N4["N4: searchOneCategory"] + N4 -.-> N3 + N3 --> N5["N5: loading store"] + N3 --> N6["N6: results store"] + N5 -.-> U2["U2: loading spinner"] + N6 -.-> U3["U3: results list"] + + classDef ui fill:#ffb6c1,stroke:#d87093,color:#000 + classDef nonui fill:#d3d3d3,stroke:#808080,color:#000 + class U1,U2,U3 ui + class N1,N2,N3,N4,N5,N6 nonui +``` + +### Line Conventions + +| Line Style | Mermaid Syntax | Use | +|------------|----------------|-----| +| Solid (`-->`) | `A --> B` | Wires Out: calls, triggers, writes | +| Dashed (`-.->`) | `A -.-> B` | Returns To: return values, data store reads | +| Labeled `...` | `A -.->|...| B` | Abbreviated flow: intermediate steps omitted | + +#### Abbreviating Out-of-Scope Flows + +When a data flow has intermediate steps that aren't relevant to the breadboard's scope, abbreviate by wiring directly from source to destination with a `...` label: + +``` +S4 -.->|...| U6 +``` + +This says "data flows from S4 to U6, with intermediate steps omitted." Use this when: +- The flow exists but its internals are out of scope +- You need to show where data originates without detailing the query chain +- The breadboard focuses on one workflow (e.g., editing) but needs to acknowledge another (e.g., viewing) + +### ID Prefixes + +| Prefix | Type | Example | +|--------|------|---------| +| **P** | Places | P1, P2, P3 | +| **U** | UI affordances | U1, U2, U3 | +| **N** | Code affordances | N1, N2, N3 | +| **S** | Data stores | S1, S2, S3 | + +### Color Conventions + +| Type | Color | Hex | +|------|-------|-----| +| Places (subgraphs) | White/transparent | — | +| UI affordances | Pink | `#ffb6c1` | +| Code affordances | Grey | `#d3d3d3` | +| Data stores | Lavender | `#e6e6fa` | +| Chunks | Light blue | `#b3e5fc` | +| Place references | Pink, dashed border | `#ffb6c1` | + +``` +classDef ui fill:#ffb6c1,stroke:#d87093,color:#000 +classDef nonui fill:#d3d3d3,stroke:#808080,color:#000 +classDef store fill:#e6e6fa,stroke:#9370db,color:#000 +classDef chunk fill:#b3e5fc,stroke:#0288d1,color:#000,stroke-width:2px +classDef placeRef fill:#ffb6c1,stroke:#d87093,stroke-width:2px,stroke-dasharray:5 5 +``` + +### Subgraph Labels and Place IDs + +Use the Place ID as the subgraph ID so navigation wiring connects properly: + +```mermaid +flowchart TB +subgraph P1["P1: CMS Page (Read Mode)"] + U1["U1: Edit button"] + N1["N1: toggleEditMode()"] +end + +subgraph P2["P2: CMS Page (Edit Mode)"] + U2["U2: Save button"] + U3["U3: Add button"] +end + +%% Navigation wires to Place ID +N1 --> P2 +``` + +| Type | ID Pattern | Label Pattern | Purpose | +|------|------------|---------------|---------| +| Place | `P1`, `P2`... | `P1: Page Name` | A bounded context the user visits | +| Trigger | — | `TRIGGER: Name` | An event that kicks off a flow (not navigable) | +| Component | — | `COMPONENT: Name` | Reusable UI+logic that appears in multiple places | +| System | — | `SYSTEM: Name` | When spanning multiple applications | + +**Key point:** The subgraph ID (`P1`, `P2`) must match the Place ID from the Places table. This allows navigation wires like `N1 --> P2` to connect to the Place boundary. + +### When spanning multiple systems + +```mermaid +flowchart TB + subgraph frontend["SYSTEM: Frontend"] + U1["U1: submit button"] + N1["N1: handleSubmit()"] + end + + subgraph backend["SYSTEM: Backend API"] + N10["N10: POST /orders"] + N11["N11: orderService.create()"] + end + + U1 --> N1 + N1 --> N10 + N10 --> N11 +``` + +### Workflow Step Annotations (Optional) + +When breadboarding a specific workflow, you can optionally add numbered step markers to help readers follow the sequence visually. This is useful when: +- The diagram is complex and the workflow path isn't obvious +- You want to guide someone through a specific user journey +- The breadboard will be used as a walkthrough or teaching tool + +**Format:** + +Add a Workflow Guide table before the diagram: + +```markdown +| Step | Action | Where to look | +|------|--------|---------------| +| **1** | Click "Edit" button | U1 → N1 → S1 | +| **2** | Edit mode activates | S1 → N2 → U3 | +| **3** | Click "Add" | U3 → N3 → N8 | +``` + +Add step marker nodes in the Mermaid diagram using stadium-shaped nodes: + +```mermaid +flowchart TB + %% Step markers + step1(["1 - CLICK EDIT"]) + step2(["2 - EDIT MODE ON"]) + step3(["3 - CLICK ADD"]) + + %% Connect steps to relevant affordances with dashed lines + step1 -.-> U1 + step2 -.-> N2 + step3 -.-> U3 + + %% Style step markers green + classDef step fill:#90EE90,stroke:#228B22,color:#000,font-weight:bold + class step1,step2,step3 step +``` + +**Formatting notes:** +- Use `"1 - ACTION"` format (number, space, hyphen, space, action) +- Avoid `"1. ACTION"` — the period triggers Mermaid's markdown list parser +- Avoid `"1) ACTION"` — parentheses can also cause parsing issues +- Connect step markers to affordances with dashed lines (`-.->`) +- Style steps green to distinguish from UI (pink) and Code (grey) affordances + +--- + +## Slicing a Breadboard + +Slicing takes a breadboard and groups its affordances into **vertical implementation slices**. See **Example B** below for a complete slicing example. + +**Input:** +- Breadboard (affordance tables with wiring) +- Shape (R + mechanisms) — guides what demos matter + +**Output:** +- Breadboard with affordances assigned to slices V1–V9 (max 9 slices) + +### What is a Vertical Slice? + +A vertical slice is a group of UI and Code affordances that does something demo-able. It cuts through all layers (UI, logic, data) to deliver a working increment. + +The opposite is a horizontal slice — doing work on one layer (e.g., "set up all the data models") that isn't clickable from the interface. + +### The Key Constraint + +**Every slice must have visible UI that can be demoed.** A slice without UI is a horizontal layer, not a vertical slice. + +- ✅ "Self-serve Signing Path" (demo: checkout → sign → see signature) +- ❌ "Database Schema" (no demo possible) + +**Demo-able means:** +- Has an entry point (UI interaction or trigger) +- Has an observable output (UI renders, effect occurs) +- Shows meaningful progress toward the R + +The shape guides what counts as "meaningful progress" — you're not just grouping affordances arbitrarily, you're grouping them to demonstrate mechanisms working. + +### Slice Size + +- **Too small:** Only 1-2 UI affordances, no meaningful demo → merge with related slice +- **Too big:** 15+ affordances or multiple unrelated journeys → split +- **Right size:** A coherent journey with a clear "watch me do this" demo + +Aim for ≤9 slices. If you need more, the shape may be too large for one cycle. + +### Wires to Future Slices + +A slice may contain affordances with Wires Out pointing to affordances in later slices. These wires exist in the breadboard but aren't implemented yet — they're stubs or no-ops until that later slice is built. + +This is normal. The breadboard shows the complete system; slicing shows the order of implementation. + +### Procedure + +**Step 1: Identify the minimal demo-able increment** + +Look at your breadboard and shape. Ask: "What's the smallest subset that demonstrates the core mechanism working?" + +Usually this is: +- The core data fetch +- Basic rendering +- No search, no pagination, no state persistence yet + +This becomes V1. + +**Step 2: Layer additional capabilities as slices** + +Look at the mechanisms in your shape. Each slice should demonstrate a mechanism working: +- V2: Search input (demonstrates the search mechanism) +- V3: Pagination/infinite scroll (demonstrates the pagination mechanism) +- V4: URL state persistence (demonstrates the state preservation mechanism) +- etc. + +**Max 9 slices.** If you have more, combine related mechanisms. Features that don't make sense alone should be in the same slice. + +**Step 3: Assign affordances to slices** + +Go through every affordance and assign it to the slice where it's first needed to demo that slice's mechanism: + +| Slice | Mechanism | Affordances | +|-------|-----------|-------------| +| V1 | Core display | U2, U3, N3, N4, N5, N6, N7 | +| V2 | Search | U1, N1, N2 | +| V3 | Pagination | U10, N11, N12, N13 | + +Some affordances may have Wires Out to later slices — that's fine. They're implemented in their assigned slice; the wires just don't do anything yet. + +**Step 4: Create per-slice affordance tables** + +For each slice, extract just the affordances being added: + +**V2: Search Works** + +| # | Component | Affordance | Control | Wires Out | Returns To | +|---|-----------|------------|---------|-----------|------------| +| U1 | search-detail | search input | type | → N1 | — | +| N1 | search-detail | `activeQuery.next()` | call | → N2 | — | +| N2 | search-detail | `activeQuery` subscription | observe | → N3 | — | + +**Step 5: Write a demo statement for each slice** + +Each slice needs a concrete demo that shows its mechanism working toward the R: +- V1: "Widget shows real data from the API" +- V2: "Type 'dharma', results filter live" +- V3: "Scroll down, more items load" + +The demo should be something you can show a stakeholder that demonstrates progress. + +### Visualizing Slices in Mermaid + +Show the complete breadboard in every slice diagram, but use styling to distinguish scope: + +| Category | Style | Description | +|----------|-------|-------------| +| **This slice** | Bright color | Affordances being added | +| **Already built** | Solid grey | Previous slices | +| **Future** | Transparent, dashed border | Not yet built | + +```mermaid +flowchart TB + U1["U1: search input"] + U2["U2: loading spinner"] + N1["N1: activeQuery.next()"] + N2["N2: subscription"] + N3["N3: performSearch"] + + U1 --> N1 + N1 --> N2 + N2 --> N3 + N3 --> U2 + + %% V2 scope (this slice) = green + classDef thisSlice fill:#90EE90,stroke:#228B22,color:#000 + %% Already built (V1) = grey + classDef built fill:#d3d3d3,stroke:#808080,color:#000 + %% Future = transparent dashed + classDef future fill:none,stroke:#ddd,color:#bbb,stroke-dasharray:3 3 + + class U1,N1,N2 thisSlice + class U2,N3 built +``` + +This lets stakeholders see: +- What's being built now (highlighted) +- What already exists (grey) +- What's coming later (faded) + +### Slice Summary Format + +| # | Slice | Mechanism | Demo | +|---|-------|-----------|------| +| V1 | Widget with real data | F1, F4, F6 | "Widget shows letters from API" | +| V2 | Search works | F3 | "Type to filter results" | +| V3 | Infinite scroll | F5 | "Scroll down, more load" | +| V4 | URL state | F2 | "Refresh preserves search" | + +The Mechanism column references parts from the shape, showing which mechanisms each slice demonstrates. + +--- +--- + +# Examples + +## Example A: Mapping an Existing System + +This example shows breadboarding an existing system to understand how data flows through multiple entry points. + +### Input + +Workflow to understand: "How is `admin_organisation_countries` modified and read downstream? There are multiple entry points: manual edit, checkbox toggle, and batch job." + +### Output + +**UI Affordances** + +| # | Component | Affordance | Control | Wires Out | Returns To | +|---|-----------|------------|---------|-----------|------------| +| U1 | SSO Admin | `role_profiles` checkboxes | render | — | — | +| U2 | SSO Admin | "Country Admin" checkbox | click | toggles selection | — | +| U3 | SSO Admin | `admin_countries` filter_horizontal | render | — | — | +| U4 | SSO Admin | Available countries list | render | — | — | +| U5 | SSO Admin | Selected countries list | render | — | — | +| U6 | SSO Admin | Add → / Remove ← | click | modifies selection | — | +| U7 | SSO Admin | Save button | click | → N3 | — | +| U20 | DWConnect | "Country admins" section | render | — | — | +| U21 | (unknown) | System email "From" field | render | — | — | + +**Code Affordances** + +| # | Component | Affordance | Control | Wires Out | Returns To | +|---|-----------|------------|---------|-----------|------------| +| N1 | sso/accounts/admin | `get_fieldsets()` | call | → U3 (conditional) | — | +| N2 | sso/accounts/models | `get_administrable_user_countries()` | call | — | → U4 | +| N3 | sso/accounts/admin | `save_form()` | call | → N4, → N5 | — | +| N4 | Django Admin | Form M2M save | call | → S2 | — | +| N5 | sso/forms/mixins | `_update_user_m2m()` | call | → S1, → N6 | — | +| N6 | sso/signals | `user_m2m_field_updated` signal | signal | → N10 | — | +| N7 | CLI/Scheduler | `manage.py dwbn_cleanup` | invoke | → N15 | — | +| N10 | sso-dwbn-theme | `dwbn_user_m2m_field_updated()` | receive | → N11 | — | +| N11 | sso-dwbn-theme | `dwbn_user_m2m_field_updated_task()` | call | → N12 | — | +| N12 | sso-dwbn-theme | Country Admin added AND zero admin countries? | conditional | → N20 | — | +| N15 | sso-dwbn-theme | `admin_changes()` | call | → N16 | — | +| N16 | sso-dwbn-theme | For each Country Admin: home center country missing? | loop | → N20 | — | +| N20 | sso-dwbn-theme | Get home center's country | call | → N21 | — | +| N21 | sso-dwbn-theme | `admin_organisation_countries.add()` | call | → S2 | — | +| N22 | sso-dwbn-theme | `update_last_modified()` | call | — | — | +| N30 | dwconnect2-backend | `findCenterAdmins()` | call | — | → U20 | +| N31 | sso/api | `get_object_data()` | call | — | → external | + +**Data Stores** + +| # | Store | Description | +|---|-------|-------------| +| S1 | `role_profiles` | M2M: which role profiles a user has | +| S2 | `admin_organisation_countries` | M2M: which countries a user administers | +| S3 | `organisations` | User's home center(s) | + +**Mermaid Diagram** + +```mermaid +flowchart TB + subgraph stores["DATA STORES"] + S1["S1: role_profiles"] + S2["S2: admin_organisation_countries"] + S3["S3: organisations"] + end + + subgraph ssoAdmin["PLACE: SSO Admin — User Change Page"] + subgraph permissions["Permissions fieldset"] + U1["U1: role_profiles checkboxes"] + U2["U2: 'Country Admin' checkbox"] + end + + subgraph userAdmin["User admin fieldset (superuser only)"] + U3["U3: admin_countries filter_horizontal"] + U4["U4: Available countries"] + U5["U5: Selected countries"] + U6["U6: Add → / Remove ←"] + end + + U7["U7: Save button"] + N1["N1: get_fieldsets()"] + N2["N2: get_administrable_user_countries()"] + N3["N3: save_form()"] + N4["N4: Form M2M save"] + N5["N5: _update_user_m2m()"] + N6["N6: user_m2m_field_updated signal"] + + N1 -->|is_superuser| userAdmin + U3 --> U4 + U3 --> U5 + U6 --> U5 + N2 -.-> U4 + + U2 --> U7 + U6 --> U7 + U7 --> N3 + N3 --> N4 + N3 --> N5 + N5 --> N6 + end + + subgraph trigger["TRIGGER: Batch Cleanup"] + N7["N7: manage.py dwbn_cleanup"] + end + + subgraph theme["sso-dwbn-theme"] + N10["N10: dwbn_user_m2m_field_updated()"] + N11["N11: dwbn_user_m2m_field_updated_task()"] + N12["N12: Country Admin added AND zero admin countries?"] + N15["N15: admin_changes()"] + N16["N16: For each Country Admin: home center country missing?"] + N20["N20: Get home center's country"] + N21["N21: admin_organisation_countries.add()"] + N22["N22: update_last_modified()"] + + N6 --> N10 + N10 --> N11 + N11 --> N12 + N7 --> N15 + N15 --> N16 + N12 -->|yes| N20 + N16 -->|yes| N20 + N20 --> N21 + N21 --> N22 + end + + subgraph dwconnect["PLACE: DWConnect — Center Page"] + N30["N30: findCenterAdmins()"] + U20["U20: 'Country admins' section"] + + N30 --> U20 + end + + subgraph api["TRIGGER: External API Request"] + N31["N31: get_object_data()"] + end + + U21["U21: System email 'From' field"] + + N4 --> S2 + N5 --> S1 + N21 --> S2 + S1 -.-> N15 + S3 -.-> N16 + S3 -.-> N20 + S2 -.-> U5 + S2 -.-> N30 + S2 -.-> N31 + S2 -.-> U21 + + classDef ui fill:#ffb6c1,stroke:#d87093,color:#000 + classDef nonui fill:#d3d3d3,stroke:#808080,color:#000 + classDef store fill:#e6e6fa,stroke:#9370db,color:#000 + classDef condition fill:#fffacd,stroke:#daa520,color:#000 + classDef trigger fill:#98fb98,stroke:#228b22,color:#000 + + class U1,U2,U3,U4,U5,U6,U7,U20,U21 ui + class N1,N2,N3,N4,N5,N6,N10,N11,N15,N20,N21,N22,N30,N31 nonui + class N12,N16 condition + class N7 trigger + class S1,S2,S3 store +``` + +--- + +## Example B: Designing from Shaped Parts + +--- + +### Part 1: Shaping Context (Input to Breadboarding) + +This section shows what comes FROM shaping — the requirements, existing patterns identified, and sketched parts. This is the INPUT that breadboarding receives. + +> **Note:** This example uses shaping terminology. In shaping, you define requirements (Rs), identify existing patterns to reuse, and sketch a solution as parts/mechanisms. Breadboarding takes this shaped solution and details out the concrete affordances and wiring. + +**The R (Requirements)** + +| ID | Requirement | +|----|-------------| +| R0 | Make content searchable from the index page | +| R2 | Navigate back to pagination state when returning from detail | +| R3 | Navigate back to search state when returning from detail | +| R4 | Search/pagination state survives page refresh | +| R5 | Browser back button restores previous search/pagination state | +| R9 | Search should debounce input (not fire on every keystroke) | +| R10 | Search should require minimum 3 characters | +| R11 | Loading and empty states should provide user feedback | + +**Existing System with Reusable Patterns (S-CUR)** + +The app already has a global search page that implements most of these Rs. During shaping, it was documented at the parts/mechanism level: + +| Part | Mechanism | +|------|-----------| +| **S-CUR1** | **URL state & initialization** | +| S-CUR1.1 | Router queryParams observable provides `{q, category}` | +| S-CUR1.2 | `initializeState(params)` sets query and category from URL | +| S-CUR1.3 | On page load, triggers initial search from URL state | +| **S-CUR2** | **Search input** | +| S-CUR2.1 | Search input binds to `activeQuery` BehaviorSubject | +| S-CUR2.2 | `activeQuery` subscription with 90ms debounce | +| S-CUR2.3 | Min 3 chars triggers `performNewSearch()` | +| **S-CUR3** | **Data fetching** | +| S-CUR3.1 | `performNewSearch()` sets loading state, calls search service | +| S-CUR3.2 | Search service builds Typesense filter, calls `rawSearch()` | +| S-CUR3.3 | `rawSearch()` queries Typesense, returns `{found, hits}` | +| S-CUR3.4 | Results written to `detailResult` data store | +| **S-CUR4** | **Pagination** | +| S-CUR4.1 | Scroll-to-bottom triggers `appendNextPage()` via intercomService | +| S-CUR4.2 | `appendNextPage()` increments page, calls search | +| S-CUR4.3 | New hits concatenated to existing hits | +| S-CUR4.4 | `sendMessage()` re-arms scroll detection | +| **S-CUR5** | **Rendering** | +| S-CUR5.1 | `cdr.detectChanges()` triggers template re-evaluation | +| S-CUR5.2 | Loading spinner, "no results", result count based on store | +| S-CUR5.3 | `*ngFor` renders tiles for each hit | +| S-CUR5.4 | Tile click navigates to detail page | + +**Sketched Solution: Parts that Adapt S-CUR** + +The new solution's parts explicitly reference which S-CUR patterns they adapt: + +| Part | Mechanism | Adapts | +|------|-----------|--------| +| F1 | Create widget (component, def, register) | — | +| F2 | URL state & initialization (read `?q=`, restore on load) | S-CUR1 | +| F3 | Search input (debounce, min 3 chars, triggers search) | S-CUR2 | +| F4 | Data fetching (`rawSearch()` with filter) | S-CUR3 | +| F5 | Pagination (scroll-to-bottom, append pages, re-arm) | S-CUR4 | +| F6 | Rendering (loading, empty, results list, rows) | S-CUR5 | + +--- + +### Part 2: Breadboarding (Transform Parts → Affordances) + +This is where breadboarding happens. The shaped parts become concrete affordances with explicit wiring. The output is the affordance tables and diagram. + +**UI Affordances** + +| # | Component | Affordance | Control | Wires Out | Returns To | +|---|-----------|------------|---------|-----------|------------| +| U1 | letter-browser | search input | type | → N1 | — | +| U2 | letter-browser | loading spinner | render | — | — | +| U3 | letter-browser | no results msg | render | — | — | +| U4 | letter-browser | result count | render | — | — | +| U5 | letter-browser | results list | render | → U6, U7, U8, U9 | — | +| U6 | letter-row | row click | click | → LD | — | +| U7 | letter-row | date | render | — | — | +| U8 | letter-row | subject | render | — | — | +| U9 | letter-row | teaser | render | — | — | +| U10 | letter-browser | scroll | scroll | → N11 | — | +| U11 | browser | back button | click | → N9 | — | +| U12 | letter-browser | "See all X results" | click | → LP | — | +| LD | — | Letter Detail | place | — | — | +| LP | — | Full Page | place | — | — | + +**Code Affordances** + +| # | Component | Affordance | Control | Wires Out | Returns To | +|---|-----------|------------|---------|-----------|------------| +| N1 | letter-browser | `activeQuery.next()` | call | → N2 | → U12 | +| N2 | letter-browser | `activeQuery` subscription | observe | → N3 | — | +| N3 | letter-browser | `performSearch()` | call | → N4, → N6, → N7, → N8 | — | +| N4 | typesense.service | `rawSearch()` | call | — | → N3, → N12 | +| N5 | letter-browser | `parentId` (config) | config | — | → N4 | +| N6 | letter-browser | `loading` store | write | — | → N8 | +| N7 | letter-browser | `detailResult` store | write | — | → N8, → N16 | +| N8 | letter-browser | `detectChanges()` | call | → U2, → U3, → U4, → U5 | — | +| N9 | browser | URL `?q=` | read | → N10 | — | +| N10 | letter-browser | `initializeState()` | call | → N1, → N3 | — | +| N11 | intercom.service | scroll subject | observe | → N12 | — | +| N12 | letter-browser | `appendNextPage()` | call | → N4, → N7, → N8, → N13, → N14 | — | +| N13 | intercom.service | `sendMessage()` | call | → N11 | — | +| N14 | router | `navigate()` | call | — | → N9 | +| N15 | letter-browser | if `!compact` subscribe | conditional | → N11 | — | +| N16 | letter-browser | if truncated show link | conditional | → U12 | — | +| N17 | letter-browser | `compact` (config) | config | — | → N4, → N15, → N16 | +| N18 | letter-browser | `fullPageRoute` (config) | config | — | → U12 | + +**Mermaid Diagram** + +```mermaid +flowchart TB + subgraph lettersIndex["PLACE: Letters Index Page"] + subgraph letterBrowser["COMPONENT: letter-browser"] + U1["U1: search input"] + U2["U2: loading spinner"] + U3["U3: no results msg"] + U4["U4: result count"] + U5["U5: results list"] + U12["U12: See all X results"] + + N1["N1: activeQuery.next"] + N2["N2: activeQuery sub"] + N3["N3: performSearch"] + N6["N6: loading store"] + N7["N7: detailResult store"] + N8["N8: detectChanges"] + N10["N10: initializeState"] + N16["N16: if truncated show link"] + N5["N5: parentId (config)"] + N17["N17: compact (config)"] + N18["N18: fullPageRoute (config)"] + + subgraph pagination["PAGINATION"] + U10["U10: scroll"] + N15["N15: if !compact subscribe"] + N12["N12: appendNextPage"] + end + end + end + + subgraph letterRow["COMPONENT: letter-row"] + U6["U6: row click"] + U7["U7: date"] + U8["U8: subject"] + U9["U9: teaser"] + end + + subgraph browser["BROWSER"] + U11["U11: back button"] + N9["N9: URL ?q="] + N14["N14: Router.navigate"] + end + + subgraph services["SERVICES"] + N4["N4: rawSearch"] + N11["N11: intercom subject"] + N13["N13: sendMessage"] + end + + subgraph letterDetail["PLACE: Letter Detail Page"] + LD["Letter Detail"] + end + + U1 -->|type| N1 + N1 --> N2 + N2 -->|debounce 90ms, min 3| N3 + + N3 --> N4 + N3 --> N6 + N3 --> N7 + N3 --> N8 + + N4 -.-> N3 + N4 -.-> N12 + N6 -.-> N8 + N7 -.-> N8 + + N8 --> U2 + N8 --> U3 + N8 --> U4 + N8 --> U5 + + U5 --> U6 + U5 --> U7 + U5 --> U8 + U5 --> U9 + + U6 -->|navigate| LD + U11 -->|restore| N9 + N9 --> N10 + N10 --> N1 + N10 --> N3 + + U10 --> N11 + N15 -->|if !compact| N11 + N11 --> N12 + N12 --> N4 + N12 --> N7 + N12 --> N8 + N12 --> N13 + N12 --> N14 + N13 -->|re-arm| N11 + + N5 -.->|filter| N4 + N17 -.-> N4 + N17 -.-> N15 + N17 -.-> N16 + N18 -.-> U12 + N14 -.->|URL| N9 + + N1 -.-> U12 + N7 -.-> N16 + N16 -->|if truncated| U12 + U12 -->|navigate with ?q| LP["Full Page"] + + classDef ui fill:#ffb6c1,stroke:#d87093,color:#000 + classDef nonui fill:#d3d3d3,stroke:#808080,color:#000 + + class U1,U2,U3,U4,U5,U6,U7,U8,U9,U10,U11,U12,LD,LP ui + class N1,N2,N3,N4,N5,N6,N7,N8,N9,N10,N11,N12,N13,N14,N15,N16,N17,N18 nonui +``` + +**Slicing the Breadboard** + +With the full breadboard complete, slice it into vertical increments. Each slice demonstrates a mechanism working: + +**Slice Summary** + +| # | Slice | Mechanism | Affordances | Demo | +|---|-------|-----------|-------------|------| +| V1 | Widget with real data | F1, F4, F6 | U2-U9, N3-N8, LD | "Widget shows real data" | +| V2 | Search works | F3 | U1, N1, N2 | "Type 'dharma', results filter" | +| V3 | Infinite scroll | F5 | U10, N11-N13 | "Scroll down, more load" | +| V4 | URL state | F2 | U11, N9, N10, N14 | "Refresh preserves search" | +| V5 | Compact mode | — | U12, N15-N18, LP | "Shows 'See all' link" | + +**Slice Diagram** + +```mermaid +flowchart TB + subgraph slice1["V1: WIDGET WITH REAL DATA"] + U2["U2: loading spinner"] + U3["U3: no results msg"] + U4["U4: result count"] + U5["U5: results list"] + U6["U6: row click"] + U7["U7: date"] + U8["U8: subject"] + U9["U9: teaser"] + + N3["N3: performSearch"] + N4["N4: rawSearch"] + N5["N5: parentId (config)"] + N6["N6: loading store"] + N7["N7: detailResult store"] + N8["N8: detectChanges"] + LD["Letter Detail"] + end + + subgraph slice2["V2: SEARCH WORKS"] + U1["U1: search input"] + N1["N1: activeQuery.next"] + N2["N2: activeQuery sub"] + end + + subgraph slice3["V3: INFINITE SCROLL"] + U10["U10: scroll"] + N11["N11: intercom subject"] + N12["N12: appendNextPage"] + N13["N13: sendMessage"] + end + + subgraph slice4["V4: URL STATE"] + U11["U11: back button"] + N9["N9: URL ?q="] + N10["N10: initializeState"] + N14["N14: Router.navigate"] + end + + subgraph slice5["V5: COMPACT MODE"] + U12["U12: See all X results"] + N15["N15: if !compact subscribe"] + N16["N16: if truncated show link"] + N17["N17: compact (config)"] + N18["N18: fullPageRoute (config)"] + LP["Full Page"] + end + + U1 -->|type| N1 + N1 --> N2 + N2 -->|debounce| N3 + + N3 --> N4 + N3 --> N6 + N3 --> N7 + N3 --> N8 + N4 -.-> N3 + N5 -.->|filter| N4 + + N6 -.-> N8 + N7 -.-> N8 + N8 --> U2 + N8 --> U3 + N8 --> U4 + N8 --> U5 + U5 --> U6 + U5 --> U7 + U5 --> U8 + U5 --> U9 + U6 -->|navigate| LD + + U11 -->|restore| N9 + N9 --> N10 + N10 --> N1 + N10 --> N3 + + U10 --> N11 + N11 --> N12 + N12 --> N4 + N12 --> N7 + N12 --> N8 + N12 --> N13 + N12 --> N14 + N13 -->|re-arm| N11 + N4 -.-> N12 + N14 -.->|URL| N9 + + N15 -->|if !compact| N11 + N17 -.-> N4 + N17 -.-> N15 + N17 -.-> N16 + N18 -.-> U12 + N1 -.-> U12 + N7 -.-> N16 + N16 -->|if truncated| U12 + U12 -->|navigate with ?q| LP + + style slice1 fill:#e8f5e9,stroke:#4caf50,stroke-width:2px + style slice2 fill:#e3f2fd,stroke:#2196f3,stroke-width:2px + style slice3 fill:#fff3e0,stroke:#ff9800,stroke-width:2px + style slice4 fill:#f3e5f5,stroke:#9c27b0,stroke-width:2px + style slice5 fill:#fff8e1,stroke:#ffc107,stroke-width:2px + + classDef ui fill:#ffb6c1,stroke:#d87093,color:#000 + classDef nonui fill:#d3d3d3,stroke:#808080,color:#000 + + class U1,U2,U3,U4,U5,U6,U7,U8,U9,U10,U11,U12,LD,LP ui + class N1,N2,N3,N4,N5,N6,N7,N8,N9,N10,N11,N12,N13,N14,N15,N16,N17,N18 nonui +``` diff --git a/docs/specs/modules/skills/shaping/SKILL.md b/docs/specs/modules/skills/shaping/SKILL.md new file mode 100644 index 00000000..9c228fe3 --- /dev/null +++ b/docs/specs/modules/skills/shaping/SKILL.md @@ -0,0 +1,798 @@ +--- +name: shaping +description: Use this methodology when collaboratively shaping a solution with the user - iterating on problem definition (requirements) and solution options (shapes). +--- + +# Shaping Methodology + +A structured approach for collaboratively defining problems and exploring solution options. + +--- + +## Multi-Level Consistency (Critical) + +Shaping produces documents at different levels of abstraction. **Truth must stay consistent across all levels.** + +### The Document Hierarchy (high to low) + +1. **Big Picture** — highest level, designed for quick context acquisition +2. **Shaping doc** — ground truth for R's, shapes, parts, fit checks +3. **Slices doc** — ground truth for slice definitions, breadboards +4. **Individual slice plans** (V1-plan, etc.) — ground truth for implementation details + +### The Principle + +Each level summarizes or provides a view into the level(s) below it. Lower levels contain more detail; higher levels are designed views that help acquire context quickly. + +**Changes ripple in both directions:** + +- **Change at high level → trickles down:** If you flag a part as ⚠️ in the Big Picture, update the shaping doc's parts table too. +- **Change at low level → trickles up:** If a slice plan reveals a new mechanism or changes the scope of a slice, the Slices doc and Big Picture must reflect that. + +### The Practice + +Whenever making a change: + +1. **Identify which level you're touching** +2. **Ask: "Does this affect documents above or below?"** +3. **Update all affected levels in the same operation** +4. **Never let documents drift out of sync** + +The Big Picture isn't a static snapshot — it's a live summary that must stay connected to its source documents. The system only works if the levels are consistent with each other. + +--- + +## Starting a Session + +When kicking off a new shaping session, offer the user both entry points: + +- **Start from R (Requirements)** — Describe the problem, pain points, or constraints. Build up requirements and let shapes emerge. +- **Start from S (Shapes)** — Sketch a solution already in mind. Capture it as a shape and extract requirements as you go. + +There is no required order. Shaping is iterative — R and S inform each other throughout. + +## Working with an Existing Shaping Doc + +When the shaping doc already has a selected shape: + +1. **Display the fit check for the selected shape only** — Show R × [selected shape] (e.g., R × F), not all shapes +2. **Summarize what is unsolved** — Call out any requirements that are Undecided, or where the selected shape has ❌ + +This gives the user immediate context on where the shaping stands and what needs attention. + +--- + +## Core Concepts + +### R: Requirements +A numbered set defining the problem space. + +- **R0, R1, R2...** are members of the requirements set +- Requirements are negotiated collaboratively - not filled in automatically +- Track status: Core goal, Undecided, Leaning yes/no, Must-have, Nice-to-have, Out +- Requirements extracted from fit checks should be made standalone (not dependent on any specific shape) +- **R states what's needed, not what's satisfied** — satisfaction is always shown in a fit check (R × S) + +### S: Shapes (Solution Options) +Letters represent mutually exclusive solution approaches. + +- **A, B, C...** are top-level shape options (you pick one) +- **C1, C2, C3...** are components/parts of Shape C (they combine) +- **C3-A, C3-B, C3-C...** are alternative approaches to component C3 (you pick one) + +### Shape Titles +Give shapes a short descriptive title that characterizes the approach. Display the title when showing the shape: + +```markdown +## E: Modify CUR in place to follow S-CUR + +| Part | Mechanism | +|------|-----------| +| E1 | ... | +``` + +Good titles capture the essence of the approach in a few words: +- ✅ "E: Modify CUR in place to follow S-CUR" +- ✅ "C: Two data sources with hybrid pagination" +- ❌ "E: The solution" (too vague) +- ❌ "E: Add search to widget-grid by swapping..." (too long) + +### Notation Hierarchy + +| Level | Notation | Meaning | Relationship | +|-------|----------|---------|--------------| +| Requirements | R0, R1, R2... | Problem constraints | Members of set R | +| Shapes | A, B, C... | Solution options | Pick one from S | +| Components | C1, C2, C3... | Parts of a shape | Combine within shape | +| Alternatives | C3-A, C3-B... | Approaches to a component | Pick one per component | + +### Notation Persistence +Keep notation throughout as an audit trail. When finalizing, compose new options by referencing prior components (e.g., "Shape E = C1 + C2 + C3-A"). + +## Phases + +Shaping moves through two phases: + +``` +Shaping → Slicing +``` + +| Phase | Purpose | Output | +|-------|---------|--------| +| **Shaping** | Explore the problem and solution space, select and detail a shape | Shaping doc with R, shapes, fit checks, breadboard | +| **Slicing** | Break down for implementation | Vertical slices with demo-able UI | + +### Phase Transition + +**Shaping → Slicing** happens when: +- A shape is selected (passes fit check, feels right) +- The shape has been breadboarded into concrete affordances +- We need to plan implementation order + +You can't slice without a breadboarded shape. + +### Big Picture Through Time + +The Big Picture is a living document that reflects the current state of truth at lower levels: + +| Stage | Big Picture Contains | +|-------|---------------------| +| **Exploring shapes** | Not needed yet — still comparing options | +| **Shape selected** | Frame + Shape (fit check, parts, breadboard) | +| **Shape breadboarded** | Same, with full wiring diagram | +| **After slicing** | Adds Slices section (sliced breadboard + grid) | +| **During implementation** | Updated as slices complete (✅/⏳ status) | + +The Big Picture always summarizes what exists in the ground truth documents. It evolves as shaping and implementation progress. + +--- + +## Fit Check (Decision Matrix) + +THE fit check is the single table comparing all shapes against all requirements. Requirements are rows, shapes are columns. This is how we decide which shape to pursue. + +### Format + +```markdown +## Fit Check + +| Req | Requirement | Status | A | B | C | +|-----|-------------|--------|---|---|---| +| R0 | Make items searchable from index page | Core goal | ✅ | ✅ | ✅ | +| R1 | State survives page refresh | Must-have | ✅ | ❌ | ✅ | +| R2 | Back button restores state | Must-have | ❌ | ✅ | ✅ | + +**Notes:** +- A fails R2: [brief explanation] +- B fails R1: [brief explanation] +``` + +### Conventions +- **Always show full requirement text** — never abbreviate or summarize requirements in fit checks +- **Fit check is BINARY** — Use ✅ for pass, ❌ for fail. No other values. +- **Shape columns contain only ✅ or ❌** — no inline commentary; explanations go in Notes section +- **Never use ⚠️ or other symbols in fit check** — ⚠️ belongs only in the Parts table's flagged column +- Keep notes minimal — just explain failures + +### Comparing Alternatives Within a Component + +When comparing alternatives for a specific component (e.g., C3-A vs C3-B), use the same format but scoped to that component: + +```markdown +## C3: Component Name + +| Req | Requirement | Status | C3-A | C3-B | +|-----|-------------|--------|------|------| +| R1 | State survives page refresh | Must-have | ✅ | ❌ | +| R2 | Back button restores state | Must-have | ✅ | ✅ | +``` + +### Missing Requirements +If a shape passes all checks but still feels wrong, there's a missing requirement. Articulate the implicit constraint as a new R, then re-run the fit check. + +## Possible Actions + +These can happen in any order: + +- **Populate R** - Gather requirements as they emerge +- **Sketch a shape** - Propose a high-level approach (A, B, C...) +- **Detail (components)** - Break a shape into components (B1, B2...) +- **Detail (affordances)** - Expand a selected shape into concrete UI/Non-UI affordances and wiring +- **Explore alternatives** - For a component, identify options (C3-A, C3-B...) +- **Check fit** - Build a fit check (decision matrix) playing options against R +- **Extract Rs** - When fit checks reveal implicit requirements, add them to R as standalone items +- **Breadboard** - Map the system to understand where changes happen and make the shape more concrete +- **Spike** - Investigate unknowns to identify concrete steps needed +- **Decide** - Pick alternatives, compose final solution +- **Create Big Picture** - Once a shape is selected, create the Big Picture summary +- **Slice** - Break a breadboarded shape into vertical slices for implementation + +## Communication + +### Show Full Tables + +When displaying R (requirements) or any S (shapes), always show every row — never summarize or abbreviate. The full table is the artifact; partial views lose information and break the collaborative process. + +- Show all requirements, even if many +- Show all shape parts, including sub-parts (E1.1, E1.2...) +- Show all alternatives in fit checks + +### Why This Matters + +Shaping is collaborative negotiation. The user needs to see the complete picture to: +- Spot missing requirements +- Notice inconsistencies +- Make informed decisions +- Track what's been decided + +Summaries hide detail and shift control away from the user. + +## Spikes + +A spike is an investigation task to learn how the existing system works and what concrete steps are needed to implement a component. Use spikes when there's uncertainty about mechanics or feasibility. + +### File Management + +**Always create spikes in their own file** (e.g., `spike.md` or `spike-[topic].md`). Spikes are standalone investigation documents that may be shared or worked on independently from the shaping doc. + +### Purpose + +- Learn how the existing system works in the relevant area +- Identify **what we would need to do** to achieve a result +- Enable informed decisions about whether to proceed +- Not about effort — effort is implicit in the steps themselves +- **Investigate before proposing** — discover what already exists; you may find the system already satisfies requirements + +### Structure + +```markdown +## [Component] Spike: [Title] + +### Context +Why we need this investigation. What problem we're solving. + +### Goal +What we're trying to learn or identify. + +### Questions + +| # | Question | +|---|----------| +| **X1-Q1** | Specific question about mechanics | +| **X1-Q2** | Another specific question | + +### Acceptance +Spike is complete when all questions are answered and we can describe [the understanding we'll have]. +``` + +### Acceptance Guidelines + +Acceptance describes the **information/understanding** we'll have, not a conclusion or decision: + +- ✅ "...we can describe how users set their language and where non-English titles appear" +- ✅ "...we can describe the steps to implement [component]" +- ❌ "...we can answer whether this is a blocker" (that's a decision, not information) +- ❌ "...we can decide if we should proceed" (decision comes after the spike) + +The spike gathers information; decisions are made afterward based on that information. + +### Question Guidelines + +Good spike questions ask about mechanics: +- "Where is the [X] logic?" +- "What changes are needed to [achieve Y]?" +- "How do we [perform Z]?" +- "Are there constraints that affect [approach]?" + +Avoid: +- Effort estimates ("How long will this take?") +- Vague questions ("Is this hard?") +- Yes/no questions that don't reveal mechanics + +## Breadboards + +Use the `/breadboarding` skill to map existing systems or detail a shape into concrete affordances. Breadboarding produces: +- UI Affordances table +- Non-UI Affordances table +- Wiring diagram grouped by Place + +Invoke breadboarding when you need to: +- Map existing code to understand where changes land +- Translate a high-level shape into concrete affordances +- Reveal orthogonal concerns (parts that are independent of each other) + +### Tables Are the Source of Truth + +The affordance tables (UI and Non-UI) define the breadboard. The Mermaid diagram renders them. + +When receiving feedback on a breadboard: +1. **First** — update the affordance tables (add/remove/modify affordances, update Wires Out) +2. **Then** — update the Mermaid diagram to reflect those changes + +Never treat the diagram as the primary artifact. Changes flow from tables → diagram, not the reverse. + +### CURRENT as Reserved Shape Name + +Use **CURRENT** to describe the existing system. This provides a baseline for understanding where proposed changes fit. + +## Shape Parts + +### Flagged Unknown (⚠️) + +A mechanism can be described at a high level without being concretely understood. The **Flag** column tracks this: + +| Part | Mechanism | Flag | +|------|-----------|:----:| +| **F1** | Create widget (component, def, register) | | +| **F2** | Magic authentication handler | ⚠️ | + +- **Empty** = mechanism is understood — we know concretely how to build it +- **⚠️** = flagged unknown — we've described WHAT but don't yet know HOW + +**Why flagged unknowns fail the fit check:** + +1. **✅ is a claim of knowledge** — it means "we know how this shape satisfies this requirement" +2. **Satisfaction requires a mechanism** — some part that concretely delivers the requirement +3. **A flag means we don't know how** — we've described what we want, not how to build it +4. **You can't claim what you don't know** — therefore it must be ❌ + +Fit check is always binary — ✅ or ❌ only. There is no third state. A flagged unknown is a failure until resolved. + +This distinguishes "we have a sketch" from "we actually know how to do this." Early shapes (A, B, C) often have many flagged parts — that's fine for exploration. But a selected shape should have no flags (all ❌ resolved), or explicit spikes to resolve them. + +### Parts Must Be Mechanisms + +Shape parts describe what we BUILD or CHANGE — not intentions or constraints: + +- ✅ "Route `childType === 'letter'` to `typesenseService.rawSearch()`" (mechanism) +- ❌ "Types unchanged" (constraint — belongs in R) + +### Avoid Tautologies Between R and S + +**R** states the need/constraint (what outcome). **S** describes the mechanism (how to achieve it). If they say the same thing, the shape part isn't adding information. + +- ❌ R17: "Admins can bulk request members to sign" + C6.3: "Admin can bulk request members to sign" +- ✅ R17: "Admins can bring existing members into waiver tracking" + C6.3: "Bulk request UI with member filters, creates WaiverRequests in batch" + +The requirement describes the capability needed. The shape part describes the concrete mechanism that provides it. If you find yourself copying text from R into S, stop — the shape part should add specificity about *how*. + +### Parts Should Be Vertical Slices + +Avoid horizontal layers like "Data model" that group all tables together. Instead, co-locate data models with the features they support: + +- ❌ **B4: Data model** — Waivers table, WaiverSignatures table, WaiverRequests table +- ✅ **B1: Signing handler** — includes WaiverSignatures table + handler logic +- ✅ **B5: Request tracking** — includes WaiverRequests table + tracking logic + +Each part should be a vertical slice containing the mechanism AND the data it needs. + +### Extract Shared Logic + +When the same logic appears in multiple parts, extract it as a standalone part that others reference: + +- ❌ Duplicating "Signing handler: create WaiverSignature + set boolean" in B1 and B2 +- ✅ Extract as **B1: Signing handler**, then B2 and B3 say "→ calls B1" + +```markdown +| **B1** | **Signing handler** | +| B1.1 | WaiverSignatures table: memberId, waiverId, signedAt | +| B1.2 | Handler: create WaiverSignature + set member.waiverUpToDate = true | +| **B2** | **Self-serve signing** | +| B2 | Self-serve purchase: click to sign inline → calls B1 | +| **B3** | **POS signing via email** | +| B3.1 | POS purchase: send waiver email | +| B3.2 | Passwordless link to sign → calls B1 | +``` + +### Hierarchical Notation + +Start with flat notation (E1, E2, E3...). Only introduce hierarchy (E1.1, E1.2...) when: + +- There are too many parts to easily understand +- You're reaching a conclusion and want to show structure +- Grouping related mechanisms aids communication + +| Notation | Meaning | +|----------|---------| +| E1 | Top-level component of shape E | +| E1.1, E1.2 | Sub-parts of E1 (add later if needed) | + +Example of hierarchical grouping (used when shape is mature): + +| Part | Mechanism | +|------|-----------| +| **E1** | **Swap data source** | +| E1.1 | Modify backend indexer | +| E1.2 | Route letters to new service | +| E1.3 | Route posts to new service | +| **E2** | **Add search input** | +| E2.1 | Add input with debounce | + +## Detailing a Shape + +When a shape is selected, you can expand it into concrete affordances. This is called **detailing**. + +### Notation + +Use "Detail X" (not a new letter) to show this is a breakdown of Shape X, not an alternative: + +```markdown +## A: First approach +(shape table) + +## B: Second approach +(shape table) + +## Detail B: Concrete affordances +(affordance tables + wiring) +``` + +### What Detailing Produces + +Use the `/breadboarding` skill to produce: +- **UI Affordances table** — Things users see and interact with (inputs, buttons, displays) +- **Non-UI Affordances table** — Data stores, handlers, queries, services +- **Wiring diagram** — How affordances connect across places + +### Why "Detail X" Not "C" + +Shape letters (A, B, C...) are **mutually exclusive alternatives** — you pick one. Detailing is not an alternative; it's a deeper breakdown of the selected shape. Using a new letter would incorrectly suggest it's a sibling option. + +``` +A, B, C = alternatives (pick one) +Detail B = expansion of B (not a choice) +``` + +## Documents + +Shaping produces up to four documents. Each has a distinct role: + +| Document | Contains | Purpose | +|----------|----------|---------| +| **Big Picture** | Frame summary, Fit Check, Parts, Breadboard, Sliced Breadboard, Slices grid | 10,000-foot view — quick context for driver and developer | +| **Frame** | Source, Problem, Outcome | The "why" — concise, stakeholder-level | +| **Shaping doc** | Requirements, Shapes (CURRENT/A/B/...), Affordances, Breadboard, Fit Check | The working document — exploration and iteration happen here | +| **Slices doc** | Slice details, affordance tables per slice, wiring diagrams | The implementation plan — how to build incrementally | +| **Slice plans** | V1-plan.md, V2-plan.md, etc. | Individual implementation plans for each slice | + +### Big Picture Document + +The Big Picture is a one-page summary that shows the entire shaped feature at a glance. It helps: +- **The driver** see the whole thing and track what we're doing +- **The developer** maintain context about requirements and shape while working in slices + +**When to create:** After a shape is selected and breadboarded, optionally before slicing is complete. The Big Picture evolves as slicing and implementation progress. + +--- + +#### Structure — Exactly Three Sections + +```markdown +# [Feature Name] — Big Picture + +**Selected shape:** [Letter] ([Short description]) + +--- + +## Frame + +### Problem +- [Pain points as bullet list] + +### Outcome +- [Success criteria as bullet list] + +--- + +## Shape + +### Fit Check (R × [Selected Shape]) +[Fit check table] + +### Parts +[Parts table with Flag column] + +### Breadboard +[Full Mermaid breadboard] + +--- + +## Slices + +[Sliced breadboard — Mermaid diagram with slice boundaries] + +[Slices grid — markdown table] +``` + +--- + +#### Frame Section + +Copy Problem and Outcome from the Frame doc or shaping doc. Use bullet lists. + +```markdown +## Frame + +### Problem + +- Users cannot search letter content to find what they're looking for +- When users navigate to a letter detail and come back, pagination state is lost +- Page refresh loses any filter state + +### Outcome + +- Users can search letters by content directly from the index page +- Search and scroll state survives page refresh +- Browser back button restores previous state +``` + +--- + +#### Shape Section + +Contains three parts: Fit Check, Parts, and Breadboard. + +**Fit Check (R × [Shape]):** + +Show only the selected shape column. Full requirement text in every row. + +```markdown +### Fit Check (R × F) + +| Req | Requirement | Status | F | +|-----|-------------|--------|---| +| R0 | Make letters searchable from the index page | Core goal | ✅ | +| R2 | Navigate back to pagination state when returning from post detail | Must-have | ✅ | +| R3 | Navigate back to search state when returning from post detail | Must-have | ✅ | +``` + +**Parts table:** + +Include the Flag column for flagged unknowns. Use "Flag" as the header, ⚠️ in flagged rows. Center-align with `:----:`. + +```markdown +### Parts + +| Part | Mechanism | Flag | +|------|-----------|:----:| +| **F1** | Create `letter-browser` widget (component, def, register in CMS) | | +| **F2** | URL state & initialization (read `?q=` `?page=`, restore on load) | | +| **F3** | Search input (debounce, min 3 chars, triggers search) | ⚠️ | +``` + +**Breadboard:** + +Include the full Mermaid breadboard from the shaping doc. Use `flowchart TB` (top-to-bottom). Include: +- Place subgraphs (`subgraph lettersIndex["PLACE: Letters Index Page"]`) +- Component subgraphs nested inside places +- UI affordances (U1, U2...) and Non-UI affordances (N1, N2...) +- Wires Out as solid arrows (`-->`) +- Returns To as dashed arrows (`-.->`) +- Node styling (pink for UI, grey for Non-UI) + +Always include a legend after the breadboard: + +```markdown +**Legend:** +- **Pink nodes (U)** = UI affordances (things users see/interact with) +- **Grey nodes (N)** = Code affordances (data stores, handlers, services) +- **Solid lines** = Wires Out (calls, triggers, writes) +- **Dashed lines** = Returns To (return values, data store reads) +``` + +--- + +#### Slices Section + +Contains two parts: Sliced Breadboard and Slices Grid. + +**Sliced Breadboard:** + +A modified version of the main breadboard with slice boundaries shown as colored subgraphs. + +Key patterns: +- **Slice subgraphs**: `subgraph slice1["V1: SLICE NAME"]` — wrap each slice's affordances +- **Invisible links for ordering**: `slice1 ~~~ slice2` — forces V1 before V2 in layout +- **Colored fills**: Each slice gets a distinct fill color and matching stroke +- **Transparent nested subgraphs**: Components inside slices use `fill:transparent` to inherit the slice color + +```mermaid +flowchart TB + subgraph slice1["V1: WIDGET WITH REAL DATA"] + subgraph letterBrowser1["letter-browser"] + U2["U2: loading spinner"] + U3["U3: no results msg"] + N3["N3: performSearch"] + end + N4["N4: rawSearch"] + end + + subgraph slice2["V2: SEARCH WORKS"] + U1["U1: search input"] + N1["N1: activeQuery.next"] + end + + %% Force slice ordering (invisible links) + slice1 ~~~ slice2 + + %% Cross-slice wiring + U1 -->|type| N1 + N1 --> N3 + N3 --> N4 + + %% Slice boundary styling (colored fills) + style slice1 fill:#e8f5e9,stroke:#4caf50,stroke-width:2px + style slice2 fill:#e3f2fd,stroke:#2196f3,stroke-width:2px + + %% Nested subgraphs transparent (inherit slice color) + style letterBrowser1 fill:transparent,stroke:#888,stroke-width:1px + + %% Node styling + classDef ui fill:#ffb6c1,stroke:#d87093,color:#000 + classDef nonui fill:#d3d3d3,stroke:#808080,color:#000 + class U1,U2,U3 ui + class N1,N3,N4 nonui +``` + +**Slice color palette** (use consistently): +- V1: `fill:#e8f5e9,stroke:#4caf50` (green) +- V2: `fill:#e3f2fd,stroke:#2196f3` (blue) +- V3: `fill:#fff3e0,stroke:#ff9800` (orange) +- V4: `fill:#f3e5f5,stroke:#9c27b0` (purple) +- V5: `fill:#fff8e1,stroke:#ffc107` (yellow) +- V6: `fill:#fce4ec,stroke:#e91e63` (pink) + +**Slices Grid:** + +A markdown table with an empty header row. Aim for 2 rows × 3 columns (6 slices) or 3 rows × 3 columns (9 slices). + +Each cell contains: +- **Linked title**: `**[V1: SLICE NAME](./feature-v1-plan.md)**` (link to slice plan file) +- **Status**: `✅ COMPLETE` or `⏳ PENDING` +- **Bullet list**: 3-4 key items using `• ` (bullet + space) +- **Demo**: Italicized description of what can be demoed +- **Use `
` for line breaks** within cells +- **Use `•  `** as a spacer bullet to equalize cell heights + +```markdown +| | | | +|:--|:--|:--| +| **[V1: WIDGET WITH REAL DATA](./letter-search-v1-plan.md)**
✅ COMPLETE

• Create letter-browser widget
• Register in CMS system
• rawSearch() with parentId
• Loading, empty, results UI

*Demo: Widget shows real letters* | **[V2: SEARCH WORKS](./letter-search-v2-plan.md)**
✅ COMPLETE

• Search input + activeQuery
• Debounce 90ms, min 3 chars
• Query passed to rawSearch()
•  

*Demo: Type "dharma", results filter live* | **[V3: INFINITE SCROLL](./letter-search-v3-plan.md)**
✅ COMPLETE

• Intercom scroll subscription
• appendNextPage()
• Re-arm scroll detection
•  

*Demo: Scroll down, more letters load* | +| **[V4: URL STATE](./letter-search-v4-plan.md)**
✅ COMPLETE

• ?q= and ?page= in URL
• initializeState() on load
• Router.navigate() on change
• Back button restores state

*Demo: Search, refresh, back all restore state* | **[V5: COMPACT MODE](./letter-search-v5-plan.md)**
✅ COMPLETE

• data.compact config
• Conditional scroll subscribe
• "See all X results" link
• Navigate with ?q=query

*Demo: compact=true shows fixed items + "See all" link* | **V6: CUTOVER**
⏳ PENDING

a) Local validation + R14
b) Deploy code (safe)
c) CMS cutover
d) Code cleanup

*TODO: Typesense index job* | +``` + +Note: Slices that are pending don't need links yet. Add links when the slice plan is created. + +--- + +#### Creating a Big Picture — Step by Step + +1. **Create the file**: `[feature]-big-picture.md` in the shaping docs folder + +2. **Add header**: Feature name and selected shape with short description + +3. **Frame section**: Copy Problem and Outcome from Frame doc or shaping doc + +4. **Shape section**: + - Copy Fit Check from shaping doc, showing only selected shape column + - Copy Parts table, add Flag column if not present (mark any unknowns with ⚠️) + - Copy full Mermaid breadboard from shaping doc + - Add legend after breadboard + +5. **Slices section** (after slicing is complete): + - Create sliced breadboard by wrapping affordances in slice subgraphs + - Add invisible links between slices for ordering + - Add colored fills to slice subgraphs + - Make nested component subgraphs transparent + - Create slices grid table with linked titles, status, bullets, demos + +6. **Maintain consistency**: When lower-level docs change, update the Big Picture to reflect those changes (see Multi-Level Consistency) + +### Document Lifecycle + +``` +Frame (problem/outcome) + ↓ +Shaping (explore, detail, breadboard) + ↓ +Slices (plan implementation) + ↓ +Big Picture (summarizes all of the above) +``` + +**Frame** can be written first — it captures the "why" before any solution work begins. It contains: +- **Source** — Original requests, quotes, or material that prompted the work (verbatim) +- **Problem** — What's broken, what pain exists (distilled from source) +- **Outcome** — What success looks like (high-level, not solution-specific) + +### Capturing Source Material + +When the user provides source material during framing (user requests, quotes, emails, slack messages, etc.), **always capture it verbatim** in a Source section at the top of the frame document. + +```markdown +## Source + +> I'd like to ask again for your thoughts on a user scenario... +> +> Small reminder: at the moment, if I want to keep my country admin rights +> for Russia and Crimea while having Europe Center as my home center... + +> [Additional source material added as received] + +--- + +## Problem +... +``` + +**Why this matters:** +- The source is the ground truth — Problem/Outcome are interpretations +- Preserves context that may be relevant later +- Allows revisiting the original request if the distillation missed something +- Multiple sources can be added as they arrive during framing + +**When to capture:** +- User pastes a request or quote +- User shares an email or message from a stakeholder +- User describes a scenario they were told about +- Any raw material that informs the frame + +**Shaping doc** is where active work happens. All exploration, requirements gathering, shape comparison, breadboarding, and fit checking happens here. This is the working document and ground truth for R, shapes, parts, and fit checks. + +**Slices doc** is created when the selected shape is breadboarded and ready to build. It contains the slice breakdown, affordance tables per slice, and detailed wiring. + +**Big Picture** is created once a shape is selected. It summarizes the Frame, Shape, and (once slicing is done) Slices. It stays in sync with the ground truth documents throughout implementation. + +### File Management + +- **Shaping doc**: Update freely as you iterate — this is the ground truth +- **Slices doc**: Created when ready to slice, updated as slice scope clarifies +- **Slice plans**: Individual files (V1-plan.md, etc.) with implementation details +- **Big Picture**: Created when shape is selected, updated to reflect lower-level changes + +### Keeping Documents in Sync + +See **Multi-Level Consistency** at the top of this document. Changes at any level must ripple to affected levels above and below. + +## Slicing + +After a shape is breadboarded, slice it into vertical implementation increments. Use the `/breadboarding` skill for the slicing process — it defines what vertical slices are, the procedure for creating them, and visualization formats. + +**The flow:** +1. **Parts** → high-level mechanisms in the shape +2. **Breadboard** → concrete affordances with wiring (use `/breadboarding`) +3. **Slices** → vertical increments that can each be demoed (use `/breadboarding` slicing section) + +**Key principle:** Every slice must end in demo-able UI. A slice without visible output is a horizontal layer, not a vertical slice. + +**Document outputs:** +- **Slices doc** — slice definitions, per-slice affordance tables, sliced breadboard +- **Slice plans** — individual implementation plans (V1-plan.md, V2-plan.md, etc.) + +## Example + +User is shaping a search feature: + +```markdown +## Requirements (R) + +| ID | Requirement | Status | +|----|-------------|--------| +| R0 | Make items searchable from index page | Core goal | +| R1 | State survives page refresh | Undecided | +| R2 | Back button restores state | Undecided | + +--- + +## C2: State Persistence + +| Req | Requirement | Status | C2-A | C2-B | C2-C | +|-----|-------------|--------|------|------|------| +| R0 | Make items searchable from index page | Core goal | — | — | — | +| R1 | State survives page refresh | Undecided | ✅ | ✅ | ❌ | +| R2 | Back button restores state | Undecided | ✅ | ✅ | ✅ | + +**Notes:** +- C2-C fails R1: in-memory state lost on refresh +- C2-B satisfies R2 but requires custom popstate handler +``` diff --git a/docs/specs/modules/spikes/S1-generic-signature-derive.md b/docs/specs/modules/spikes/S1-generic-signature-derive.md new file mode 100644 index 00000000..ae932448 --- /dev/null +++ b/docs/specs/modules/spikes/S1-generic-signature-derive.md @@ -0,0 +1,135 @@ +# S1 Spike: Generic `#[derive(Signature)]` with `#[flatten]` + +## Context + +S1 is explicitly called out as a high-priority spike in shaping/design docs: generic `Signature` derive with `#[flatten]` is required for F12 and module authoring (`docs/specs/modules/shapes.md:140`, `docs/specs/modules/design_reference.md:117`, `docs/specs/modules/design_reference.md:1006`). + +Current implementation spans macro codegen in `crates/dsrs-macros/src/lib.rs` and typed runtime consumption in `crates/dspy-rs/src/core/signature.rs` + `crates/dspy-rs/src/adapter/chat.rs`. + +## Goal + +Determine whether the current macro/runtime architecture can support generic signatures with flattened fields, and identify a concrete implementation path that satisfies S1 without introducing inconsistent metadata behavior. + +## Questions + +| ID | Question | +|---|---| +| **S1-Q1** | Does the current `Signature` derive thread generics and where-bounds into generated helper types + impls? | +| **S1-Q2** | Can the current derive accept and represent `#[flatten]` fields? | +| **S1-Q3** | Do runtime signature metadata and adapter parsing/formatting support flattened field paths? | +| **S1-Q4** | What does Facet already provide for flatten + shape metadata (NIA-backed evidence)? | +| **S1-Q5** | What implementation path best satisfies S1 while staying aligned with F2 direction? | + +## Findings + +1. **S1-Q1: current derive does not forward generics into generated types/impls.** + - Evidence: helper identifiers are monomorphic (`FooInput`, `__FooOutput`, `__FooAll`) and emitted as non-generic structs (`crates/dsrs-macros/src/lib.rs:397`, `crates/dsrs-macros/src/lib.rs:398`, `crates/dsrs-macros/src/lib.rs:399`, `crates/dsrs-macros/src/lib.rs:408`, `crates/dsrs-macros/src/lib.rs:413`, `crates/dsrs-macros/src/lib.rs:418`). + - Evidence: `impl BamlType for #name` and `impl Signature for #name` are emitted without `impl_generics/ty_generics/where_clause` forwarding (`crates/dsrs-macros/src/lib.rs:594`, `crates/dsrs-macros/src/lib.rs:652`). + - Evidence: there is no generics plumbing (`split_for_impl`, `impl_generics`, `ty_generics`, `where_clause`) in this file. + - Conclusion: generic signatures cannot be derived correctly with the current codegen shape. + +2. **S1-Q2: `#[flatten]` is not accepted or represented by `#[derive(Signature)]`.** + - Evidence: derive helper attribute whitelist excludes `flatten` (`crates/dsrs-macros/src/lib.rs:22`). + - Evidence: field parsing only branches on `input`, `output`, `alias`, `format`, `check`, `assert` (`crates/dsrs-macros/src/lib.rs:198`, `crates/dsrs-macros/src/lib.rs:199`, `crates/dsrs-macros/src/lib.rs:204`, `crates/dsrs-macros/src/lib.rs:209`, `crates/dsrs-macros/src/lib.rs:211`, `crates/dsrs-macros/src/lib.rs:219`, `crates/dsrs-macros/src/lib.rs:221`). + - Evidence: field codegen emits docs + `pub field: Ty` only; no flatten marker reaches generated helper structs (`crates/dsrs-macros/src/lib.rs:424`, `crates/dsrs-macros/src/lib.rs:439`, `crates/dsrs-macros/src/lib.rs:440`). + - Conclusion: flattened signatures are blocked at macro boundary today. + +3. **S1-Q3: typed runtime metadata/parsing is top-level-key based, not path-based.** + - Evidence: `FieldSpec` contains `name/rust_name/description/type_ir/constraints/format` and no path field (`crates/dspy-rs/src/core/signature.rs:6`, `crates/dspy-rs/src/core/signature.rs:8`, `crates/dspy-rs/src/core/signature.rs:10`, `crates/dspy-rs/src/core/signature.rs:12`). + - Evidence: typed formatting does `fields.get(field_spec.rust_name)` for input and output (`crates/dspy-rs/src/adapter/chat.rs:530`, `crates/dspy-rs/src/adapter/chat.rs:531`, `crates/dspy-rs/src/adapter/chat.rs:555`, `crates/dspy-rs/src/adapter/chat.rs:556`). + - Evidence: `baml_value_fields` exposes only top-level `Class/Map` maps (`crates/dspy-rs/src/adapter/chat.rs:861`, `crates/dspy-rs/src/adapter/chat.rs:865`, `crates/dspy-rs/src/adapter/chat.rs:866`). + - Evidence: parsing writes a flat `output_map` keyed by `rust_name` and then constructs `S::Output` from that map (`crates/dspy-rs/src/adapter/chat.rs:601`, `crates/dspy-rs/src/adapter/chat.rs:607`, `crates/dspy-rs/src/adapter/chat.rs:720`, `crates/dspy-rs/src/adapter/chat.rs:739`). + - Conclusion: adding flatten only in macro codegen will still fail typed adapter roundtrip behavior. + +4. **S1-Q4: local runtime already uses Facet-driven primitives compatible with flatten-aware direction.** + - Evidence: `baml_type_ir::()` delegates to shape-based type derivation (`crates/bamltype/src/runtime.rs:96`, `crates/bamltype/src/runtime.rs:97`). + - Evidence: schema/type building entrypoints are shape-based (`crates/bamltype/src/schema_builder.rs:17`, `crates/bamltype/src/schema_builder.rs:30`). + - Evidence: runtime serialization iterates reflect traversal (`fields_for_serialize`) (`crates/bamltype/src/convert.rs:611`). + - Evidence: `serde(flatten)` is explicitly rejected in derive compatibility layer, enforcing Facet-native flatten instead (`crates/bamltype-derive/src/lib.rs:970`, `crates/bamltype-derive/src/lib.rs:973`, `crates/bamltype-derive/src/lib.rs:1003`, `crates/bamltype-derive/src/lib.rs:1006`). + - NIA evidence: external Facet docs/runtime citations for `#[facet(flatten)]` and flatten-aware reflect traversal are already captured in sibling spikes (`docs/specs/modules/spikes/S3-augmentation-deref-composition.md:47`, `docs/specs/modules/spikes/S3-augmentation-deref-composition.md:50`, `docs/specs/modules/spikes/S3-augmentation-deref-composition.md:51`). + +5. **S1-Q5: current gap is dual-surface (macro + typed runtime), not macro-only.** + - Evidence: design decision D2 already calls static `FieldSpec` arrays the legacy model for flatten/generic scenarios (`docs/specs/modules/design_reference.md:991`). + - Evidence: S1 remains marked High-priority and blocking F12/module authoring (`docs/specs/modules/design_reference.md:1006`). + - Conclusion: a viable S1 plan must include generic codegen plus typed path metadata/access changes in the same delivery track. + +6. **Current tests do not exercise S1 behavior end-to-end.** + - Evidence: positive derive tests cover non-generic, non-flatten signatures only (`crates/dsrs-macros/tests/signature_derive.rs:4`, `crates/dsrs-macros/tests/signature_derive.rs:16`). + - Evidence: trybuild UI harness covers validation failures only (`crates/dsrs-macros/tests/ui.rs:3`, `crates/dsrs-macros/tests/ui`). + - Validation run (2026-02-09): `cargo test -p dsrs_macros --tests` passes, confirming no existing regression signal for S1. + +## Options And Tradeoffs + +### Option A: Macro-only generic threading +- **What**: forward generics in generated helper structs and impl blocks only. +- **Pros**: smallest code change. +- **Cons**: does not satisfy flatten path behavior in typed adapter; fails S1-Q3. + +### Option B: Generic threading + path-aware runtime metadata (incremental) +- **What**: ship generic forwarding and flatten parsing/codegen, plus typed-path shape/path metadata and adapter path traversal. Keep legacy `FieldSpec`/`MetaSignature` surface for compatibility while typed path migrates. +- **Pros**: satisfies S1 with bounded blast radius; aligns with F2 direction without forcing full-system cutover. +- **Cons**: temporary dual metadata paths increase short-term maintenance complexity. + +### Option C: Full F2 move now (Facet-derived `SignatureSchema`) +- **What**: complete replacement of static field arrays with full Facet-derived `SignatureSchema` across typed and dynamic paths. +- **Pros**: cleanest end-state; removes bridge complexity. +- **Cons**: largest immediate surface area and integration risk for a single spike exit. + +## Decision + +**Option C: full F2 replacement.** Build `SignatureSchema` from Facet, replace `FieldSpec` everywhere, delete the old system. No incremental migration, no compatibility shims. + +Reasoning: Option A cannot satisfy S1-Q3. Option B was the spike's original recommendation (smallest correct path), but it contradicts the design principle "no parallel schema systems" and creates temporary dual metadata surfaces. Option C matches the architecture: Facet Shapes are the single source of truth (D2). S6 (incremental migration) is subsumed — there is no migration, just replacement. + +## Concrete Implementation Steps + +1. **Macro: thread generics end-to-end (`crates/dsrs-macros/src/lib.rs`)** + - Capture `input.generics` in `generate_signature_code`. + - Pass generics into `generate_helper_structs`, `generate_baml_delegation`, and `generate_signature_impl`. + - Use `split_for_impl()` and emit: + - `struct FooInput ...` + - `impl<...> BamlType for Foo<...> where ...` + - `impl<...> Signature for Foo<...> where ...` + - Exit condition: a generic non-flatten signature compiles and existing non-generic tests still pass. + +2. **Macro: add flatten parsing/validation/codegen (`crates/dsrs-macros/src/lib.rs`)** + - Add `flatten` to derive helper attr whitelist. + - Extend `ParsedField` with `is_flatten`. + - Parse `#[flatten]` in `parse_single_field`. + - Add explicit validation rules (at minimum): duplicate `flatten` rejected; `flatten` cannot be combined with leaf-only metadata (`alias`, `check`, `assert`, `format`). + - Emit `#[facet(flatten)]` on generated helper struct field when `is_flatten` is true. + - Exit condition: flatten marker survives macro expansion into helper types. + +3. **Typed runtime: add path-aware metadata bridge (`crates/dspy-rs/src/core/signature.rs`)** + - Introduce path-aware field metadata type (e.g. `FieldPathSpec`) carrying leaf name + logical path + `TypeIR`. + - Add typed-schema derivation from `S::Input` / `S::Output` shapes for typed adapter use (do not rely on static top-level `FieldSpec` arrays for flatten cases). + - Keep current `FieldSpec` APIs available for compatibility during migration. + - Exit condition: flattened leaf fields are enumerable with deterministic paths. + +4. **Typed adapter: path-based format + parse (`crates/dspy-rs/src/adapter/chat.rs`)** + - Formatting path: replace direct `fields.get(rust_name)` lookups with path navigation. + - Parsing path: replace flat `output_map.insert(rust_name, value)` with path insertion into nested `BamlValue` prior to `try_from_baml_value`. + - Preserve current behavior for non-flatten signatures. + - Exit condition: flattened output roundtrips with no dropped/misplaced fields. + +5. **Tests: add explicit S1 coverage** + - `crates/dsrs-macros/tests/signature_derive.rs`: add a generic+flatten positive case (including `where` bounds propagation assertions). + - `crates/dsrs-macros/tests/ui/*.rs`: add compile-fail cases for invalid flatten placements. + - `crates/dspy-rs` typed adapter tests: add flatten path format/parse roundtrip for generic signatures. + - Verification command: `cargo test -p dsrs_macros --tests` and targeted `dspy-rs` typed adapter tests. + +## Open Risks + +- Name-collision semantics for flattened leaves are not yet specified (e.g. sibling `answer` + flattened `inner.answer`). +- Constraint/alias behavior on flattened leaves needs a single rule path (inherit-from-inner vs explicit-on-wrapper). +- Temporary dual metadata surfaces (`FieldSpec` + path-aware typed schema) must be tightly scoped to avoid drift before S6 cutover. + +## Acceptance + +S1 is complete when: + +- A generic signature with flattened field(s) compiles and derives `Signature` successfully. +- Generated `Input`/`Output` helper types preserve generic parameters and bounds. +- Typed adapter formatting and parsing behave correctly for flattened fields (no field loss/mismatch). +- Existing non-generic signature behavior remains unchanged. +- Tests explicitly cover generic+flatten signatures in macro and runtime paths. diff --git a/docs/specs/modules/spikes/S2-dynpredictor-handle-discovery.md b/docs/specs/modules/spikes/S2-dynpredictor-handle-discovery.md new file mode 100644 index 00000000..0456c0d5 --- /dev/null +++ b/docs/specs/modules/spikes/S2-dynpredictor-handle-discovery.md @@ -0,0 +1,114 @@ +# S2 Spike: DynPredictor Handle Discovery + +## Context + +S2 asks for the concrete mechanism that lets a Facet-based walker discover predictor leaves and return usable optimizer handles (`&dyn DynPredictor` / `&mut dyn DynPredictor`) without manual traversal boilerplate. It is explicitly marked high-priority and blocks R4 in shaping/design (`docs/specs/modules/shapes.md:241`, `docs/specs/modules/design_reference.md:1007`). + +The current runtime still uses `Optimizable::parameters()` with manual `#[derive(Optimizable)]` + `#[parameter]`, so S2 must bridge from that model to automatic Facet discovery. + +## Goal + +Identify the most practical first implementation for S2 that: +- discovers `Predict` leaves automatically from module structure, +- yields stable dotted paths + mutable optimizer handles, +- is compatible with current optimizer call patterns, +- and is backed by real Facet traversal/attribute capabilities. + +## Questions + +| ID | Question | +|---|---| +| **S2-Q1** | What exact handle contract do current optimizers require from discovered predictors? | +| **S2-Q2** | How does current discovery work, and where does it fail relative to S2/R4? | +| **S2-Q3** | What Facet walker/traversal primitives already exist for structs/lists/maps/options/cycles? | +| **S2-Q4** | Can Facet attributes carry typed runtime payloads sufficient for handle extraction (including function-pointer payloads)? | +| **S2-Q5** | Which mechanism is best for obtaining `DynPredictor` handles: shape attribute payload, global registry, or another pattern? | +| **S2-Q6** | What is the concrete migration path from `Optimizable::parameters()` to Facet walker discovery without breaking optimizers? | + +## Findings + +1. Current optimizers require mutable, path-addressable handles, not just discovery metadata. + - `Optimizable` requires `parameters(&mut self) -> IndexMap` (`crates/dspy-rs/src/core/module.rs:89`). + - Optimizers repeatedly look up dotted names and mutate predictors (`crates/dspy-rs/src/optimizer/copro.rs:221`, `crates/dspy-rs/src/optimizer/mipro.rs:419`, `crates/dspy-rs/src/optimizer/gepa.rs:442`). + +2. Current discovery is manual and annotation-driven. + - `Optimizable` derive is keyed on `#[parameter]` (`crates/dsrs-macros/src/lib.rs:17`). + - Macro extraction only includes fields with that annotation (`crates/dsrs-macros/src/optim.rs:72`, `crates/dsrs-macros/src/optim.rs:78`). + - Flattening uses unsafe casts to create leaf handles (`crates/dsrs-macros/src/optim.rs:49`, `crates/dsrs-macros/src/optim.rs:54`). + +3. Predictor leaves are currently exposed only through `Optimizable` leaf behavior. + - `Predict` and `LegacyPredict` both return empty child maps (`crates/dspy-rs/src/predictors/predict.rs:503`, `crates/dspy-rs/src/predictors/predict.rs:667`). + - There is no concrete `DynPredictor` trait in current runtime code. + +4. Test coverage validates nested struct flattening, but not container traversal (`Option`/`Vec`/`Map`) or Facet auto-discovery. + - Existing tests exercise nested named-field flattening (`crates/dspy-rs/tests/test_optimizable.rs:39`, `crates/dspy-rs/tests/test_optimizable.rs:64`). + - `cargo test -p dspy-rs --test test_optimizable` passes (3/3) as of February 9, 2026. + +5. Local code proves Facet attrs can carry typed function-pointer payloads and execute them at runtime. + - `WithAdapterFns` stores function pointers and is Facet-derived (`crates/bamltype/src/facet_ext.rs:19`, `crates/bamltype/src/facet_ext.rs:23`). + - Attr extraction uses typed decoding via `attr.get_as` (`crates/bamltype/src/facet_ext.rs:41`, `crates/bamltype/src/facet_ext.rs:47`). + - Runtime paths invoke those payloads for conversion/schema work (`crates/bamltype/src/convert.rs:300`, `crates/bamltype/src/schema_builder.rs:390`). + +6. Local conversion code demonstrates the exact reflection primitives needed for S2/S5 container traversal. + - `convert.rs` imports Facet `Def` plus reflection `Partial`/`Peek` (`crates/bamltype/src/convert.rs:8`, `crates/bamltype/src/convert.rs:9`). + - Deserialization handles pointer/option/list/map recursively (`crates/bamltype/src/convert.rs:125`, `crates/bamltype/src/convert.rs:133`, `crates/bamltype/src/convert.rs:191`, `crates/bamltype/src/convert.rs:216`). + - Serialization handles option/list/map through recursive `Peek` traversal (`crates/bamltype/src/convert.rs:539`, `crates/bamltype/src/convert.rs:593`, `crates/bamltype/src/convert.rs:602`). + +7. Typed attr-payload decoding is already a normal pattern in schema paths. + - `schema_builder` resolves typed extension attrs with `attr.get_as::()` (`crates/bamltype/src/schema_builder.rs:629`, `crates/bamltype/src/schema_builder.rs:636`). + - This supports reusing the same pattern for DynPredictor accessor payloads instead of introducing a separate global registry first. + +## Candidate Mechanisms + Tradeoffs + +Decision criteria: satisfy Q1 handle contract, preserve S5 container recursion, keep migration risk low, and keep unsafe surface auditable. + +| Mechanism | Q1: mutable handle contract | Q3/Q4/S5: Facet traversal + typed payload fit | Migration risk | Verdict | +|---|---|---|---|---| +| **A. Shape-local accessor payload (`dsrs::parameter` + fn ptr payload)** | **Strong**: direct cast to `&mut dyn DynPredictor` at leaf | **Strong**: matches existing typed attr payload pattern and recursive reflection model | **Medium**: requires one audited unsafe boundary | **Best first implementation** | +| **B. Global registry (shape/type id → accessor)** | **Strong**: can return mutable handles | **Medium**: traversal still works, but handle resolution depends on external registration | **High**: init-order, registration drift, harder debugging | **Fallback only** | +| **C. Store dyn handle inside `Predict` state** | **Medium**: contract works but via extra indirection | **Weak**: bypasses Facet metadata path and adds ownership complexity | **High**: invasive runtime state changes | **Reject for V1** | + +## Recommended Approach + +**Decision:** implement **Mechanism A** for S2 V1. + +**Scope for this spike outcome:** +- **In:** shape-local accessor payload on `Predict`, Facet walker discovery, compatibility shim for current optimizers. +- **Deferred:** registry-based indirection (Mechanism B) unless later required by cross-crate runtime loading. +- **Out:** interior dyn-handle state in `Predict` (Mechanism C). + +Why this path is crisp: +- It reuses a proven local pattern (`WithAdapterFns` + typed attr payload extraction). +- It keeps discovery + extraction colocated with the type shape (fewer moving parts). +- It preserves deterministic traversal semantics needed for optimizer naming. + +## Concrete Implementation Steps + +| # | Implementation step | Testable exit criterion | +|---|---|---| +| 1 | Introduce `DynPredictor` trait and `PredictAccessorFns` payload type (opaque, fn-pointer based) | Compile-time check that `Predict: DynPredictor`; payload type is `'static + Copy` and can be stored in Facet attr grammar | +| 2 | Add `dsrs` attr grammar entries for predictor marker + accessor payload | Unit test can read `Predict::::SHAPE` attrs and decode payload via typed `get_as` | +| 3 | Implement `DynPredictor` for `Predict` and attach payload on `Predict` shape | Unit test obtains payload from shape and successfully reads/updates predictor instruction through returned dyn handle | +| 4 | Implement `named_predictors_mut` walker over Facet-reflect values (struct/list/map/option/pointer; stop descent at predictor leaves) | Snapshot test returns expected dotted paths for nested fixture module (e.g. `retrieve`, `answer.predict`) | +| 5 | Define deterministic path encoding (`field`, `[idx]`, `['key']`) + cycle guard behavior | Repeated runs (e.g. 100 iterations) return identical order/paths for the same module instance | +| 6 | Add compatibility shim from new discovery output to current optimizer mutation flow | Existing optimizer tests/smokes still mutate instructions by dotted name without changing optimizer call sites | +| 7 | Add container and failure-path tests | Tests cover `Option>`, `Vec>`, `Map>`, and missing/invalid payload decode errors | +| 8 | Migrate module examples from derive-driven parameter discovery to walker discovery | Example modules no longer require `#[derive(Optimizable)]` + `#[parameter]` for predictor discovery | + +## Acceptance + +S2 is complete when: + +- A Facet-based walker can discover all predictor leaves in nested modules without `#[parameter]` annotations. +- Discovery returns stable dotted paths and mutable handles that optimizers can use to read/update predictor state. +- `Option`/`Vec`/`Map` containment of predictors is covered by automated tests. +- Container recursion is cycle-safe and deterministic. +- Existing optimization flows remain functional through the compatibility shim. +- The mechanism is documented with clear unsafe boundaries and invariants. +- Baseline compatibility remains green (`cargo test -p dspy-rs --test test_optimizable`). + +## Open Risks + +- Unsafe cast boundary for payload-based handle extraction must be tightly documented and audited. +- Map-key ordering policy for dotted paths must be explicit to avoid optimizer cache churn across runs. +- If structural optimization later requires loading strategies from crates not linked at compile time, Mechanism B (registry fallback) may still be needed. diff --git a/docs/specs/modules/spikes/S3-augmentation-deref-composition.md b/docs/specs/modules/spikes/S3-augmentation-deref-composition.md new file mode 100644 index 00000000..fa94d71c --- /dev/null +++ b/docs/specs/modules/spikes/S3-augmentation-deref-composition.md @@ -0,0 +1,103 @@ +# S3 Spike: Augmentation Deref Composition + +## Context + +S3 is the explicit open question for F3/R13: whether nested augmentation wrappers can expose fields ergonomically via deref chaining, and what fallback is needed if that breaks (`docs/specs/modules/shapes.md:142`, `docs/specs/modules/shapes.md:45`, `docs/specs/modules/shapes.md:60`). + +The technical design already encodes tuple composition as nested wrappers and leaves multi-layer deref behavior as the unresolved spike (`docs/specs/modules/design_reference.md:357`, `docs/specs/modules/design_reference.md:362`, `docs/specs/modules/design_reference.md:370`). + +## Goal + +Pin down real Rust ergonomics for composed wrappers (read/method/mutation/pattern behavior) and identify the minimum architecture changes required to ship tuple augmentation safely. + +## Questions + +| ID | Question | +|---|---| +| **S3-Q1** | Do field reads and method calls auto-deref through multiple augmentation wrapper layers? | +| **S3-Q2** | What are the exact limits for mutation and pattern matching through wrapper layers? | +| **S3-Q3** | What in the current local macro + adapter pipeline blocks composed augmentation rollout? | +| **S3-Q4** | What does the target F2/F7 design require for path-aware flatten parsing? | + +## Findings + +1. F3 is intentionally built around nested wrappers + `Deref`; S3 is specifically about the remaining ergonomics risk. + - Evidence: `Augmentation::Wrap` requires `Deref` (`docs/specs/modules/design_reference.md:259`). + - Evidence: tuple composition is nested (`A::Wrap>`) (`docs/specs/modules/design_reference.md:361`, `docs/specs/modules/design_reference.md:362`). + - Evidence: S3 is explicitly called out for multi-layer deref behavior (`docs/specs/modules/design_reference.md:370`). + +2. Field reads and method calls through nested wrappers work with current Rust deref coercions. + - Evidence: probe assertions for `result.reasoning`, `result.confidence`, `result.answer` pass (`/tmp/s3_deref_probe.rs:66`, `/tmp/s3_deref_probe.rs:67`, `/tmp/s3_deref_probe.rs:68`). + - Evidence: method call through nested wrappers passes (`/tmp/s3_deref_probe.rs:71`). + - Validation: `rustc /tmp/s3_deref_probe.rs -o /tmp/s3_deref_probe_check && /tmp/s3_deref_probe_check` succeeded. + +3. Mutation is conditional: it works only when every wrapper layer implements `DerefMut`. + - Evidence: mutation passes in probe where both wrappers implement `DerefMut` (`/tmp/s3_deref_probe.rs:48`, `/tmp/s3_deref_probe.rs:74`). + - Evidence: without `DerefMut`, mutation fails with E0596 (`/tmp/s3_deref_no_mut.rs:46`). + - Evidence: current design sketch only shows `Deref` impl on generated wrapper (`docs/specs/modules/design_reference.md:289`). + +4. Pattern matching is not auto-deref ergonomic. + - Evidence: direct match of inner wrapper from outer value fails with E0308 (`/tmp/s3_pattern_fail.rs:37`). + - Evidence: explicit layer-by-layer destructuring works (`/tmp/s3_deref_probe.rs:78`, `/tmp/s3_deref_probe.rs:79`, `/tmp/s3_deref_probe.rs:80`). + - Evidence: explicit deref in pattern contexts is limited for non-`Copy` data (E0507) (`/tmp/s3_pattern_double_deref.rs:21`). + +5. The real rollout blocker is current top-level-only `FieldSpec` metadata/parsing, not deref read ergonomics. + - Evidence: current derive attribute set excludes `flatten` (`crates/dsrs-macros/src/lib.rs:22`). + - Evidence: generated metadata is `FieldSpec` with no field path (`crates/dsrs-macros/src/lib.rs:551`, `crates/dspy-rs/src/core/signature.rs:6`). + - Evidence: typed formatting looks up only top-level `field_spec.rust_name` (`crates/dspy-rs/src/adapter/chat.rs:530`, `crates/dspy-rs/src/adapter/chat.rs:531`). + - Evidence: typed parse inserts output values at top-level rust names (`crates/dspy-rs/src/adapter/chat.rs:607`, `crates/dspy-rs/src/adapter/chat.rs:720`, `crates/dspy-rs/src/adapter/chat.rs:739`). + +6. The target design already defines the required path-aware model; this is what must land before tuple composition is considered done. + - Evidence: `FieldSchema` includes `path` (`docs/specs/modules/design_reference.md:175`). + - Evidence: flatten reconstruction requires `["inner", "answer"]`-style paths (`docs/specs/modules/design_reference.md:334`, `docs/specs/modules/design_reference.md:336`). + - Evidence: F7 parse/format path navigation is the intended mechanism (`docs/specs/modules/design_reference.md:642`, `docs/specs/modules/design_reference.md:681`, `docs/specs/modules/design_reference.md:692`). + - Evidence: design decision D2 explicitly rejects static `FieldSpec` arrays for this use case (`docs/specs/modules/design_reference.md:991`). + - Evidence: local conversion already uses flatten-aware reflective serialization (`crates/bamltype/src/convert.rs:611`, `crates/bamltype/src/convert.rs:612`). + +## Options + Risks + +| Option | Approach | Risks | +|---|---|---| +| **A. Keep nested wrappers + ship path-aware schema/adapter** | Keep `A::Wrap>`; guarantee read/method ergonomics via `Deref`; migrate formatting/parsing to path-aware metadata (F2/F7). | Requires coordinated macro + signature + adapter migration; biggest integration surface but matches selected design. | +| **B. Generate a flat tuple wrapper** | Special-case `(A, B, ...)` into one merged wrapper instead of nesting. | More proc-macro complexity, weaker composability, drifts from F3 design and D1/D8 decisions. | +| **C. Keep nested wrappers + explicit accessor traits** | Preserve nesting, avoid deref field ergonomics by requiring trait methods for composed fields. | Regresses R2/R13 ergonomics and adds trait/coherence boilerplate. | + +## Recommended Approach + +Proceed with **Option A**. + +Refined conclusions: +- Treat nested deref as sufficient for primary ergonomics: field reads and method calls. +- Treat pattern matching as intentionally explicit API surface (not auto-deref ergonomic). +- Decide mutability contract explicitly: + - If outputs are read-only at call sites, keep `Deref` only. + - If mutable field edits are part of supported ergonomics, add `DerefMut` generation and test it. +- Gate R13 completion on path-aware parse/format migration; do not ship tuple augmentation on top-level-only `FieldSpec`. + +## Concrete Implementation Steps + +1. Add compile-pass tests for nested wrapper read and method-call ergonomics (`result.reasoning`, `result.confidence`, `result.answer`, method dispatch). +2. Add compile-fail tests for unsupported ergonomics: + - direct inner-wrapper pattern match from outer wrapper, + - mutation through wrappers when `DerefMut` is absent. +3. Finalize augmentation mutability contract (`Deref`-only vs `Deref` + `DerefMut`) and codify it in derive output + docs. +4. Implement/land path-aware signature metadata (`FieldPath`) in the typed path. +5. Migrate typed adapter formatting/parsing to path navigation/insertion instead of top-level `rust_name` map access. +6. Add integration roundtrip tests for two-layer composed augmentations ensuring no dropped fields and deterministic field ordering. +7. Document pattern-matching limitations and explicit destructuring guidance for module authors. + +## Open Risks + +- Path-aware migration spans macro output, signature traits, and adapter internals; partial rollout risks mixed metadata models. +- S1 (generic `#[derive(Signature)]` + flatten) remains a dependency for some high-value module authoring flows (`docs/specs/modules/shapes.md:140`). +- If mutable composed outputs become part of the public ergonomic promise, derive codegen and trait bounds will expand. + +## Acceptance + +S3 is complete when: + +- Tests prove nested wrapper field reads and method calls work for composed augmentations. +- Tests/docs explicitly capture pattern-matching limits and supported destructuring patterns. +- The mutability contract is explicit and test-backed (`Deref`-only or `Deref` + `DerefMut`). +- Typed prompt formatting/parsing roundtrips composed augmentation outputs using path-aware metadata. +- R13 is evaluated against the path-aware implementation, not against the legacy top-level `FieldSpec` path. diff --git a/docs/specs/modules/spikes/S4-refine-scoped-context.md b/docs/specs/modules/spikes/S4-refine-scoped-context.md new file mode 100644 index 00000000..3d926895 --- /dev/null +++ b/docs/specs/modules/spikes/S4-refine-scoped-context.md @@ -0,0 +1,121 @@ +# S4 Spike: Refine Scoped Context for Hint Injection + +## Context + +S4 asks for a concrete scoped-context mechanism for Refine hint injection and explicitly calls out three options: `tokio::task_local!`, `thread_local!` with `RefCell`, or an explicit context parameter (`docs/specs/modules/shapes.md:243`). + +Refine is currently defined as BestOfN with feedback injection that adds a `hint` field to predictor prompts on retries, but this mechanism is still unresolved (`docs/specs/modules/design_reference.md:942`). The shaping doc also marks this as one of the F11 prototyping gaps (`docs/specs/modules/shapes.md:68`, `docs/specs/modules/shapes.md:72`). + +## Goal + +Select a first-pass mechanism for scoped hint propagation in Refine that is safe under async/concurrency, works with nested module composition, and minimizes user-facing API churn. + +## Questions + +| ID | Question | +|---|---| +| **S4-Q1** | What scoped-context pattern already exists in runtime code that we can reuse? | +| **S4-Q2** | How do current settings/instruction paths constrain hint injection design? | +| **S4-Q3** | What concurrency behavior in module evaluation affects context isolation? | +| **S4-Q4** | What are the concrete tradeoffs between task-local, thread-local, and explicit-parameter approaches? | +| **S4-Q5** | What first-pass implementation path gives low risk now while preserving an escape hatch for harder async cases? | + +## Findings + +1. **There is already a production task-local scoping pattern in tracing.** + - `tokio::task_local!` is used to hold scoped trace state (`crates/dspy-rs/src/trace/context.rs:4`, `crates/dspy-rs/src/trace/context.rs:7`). + - Scope lifecycle uses `CURRENT_TRACE.scope(...).await` (`crates/dspy-rs/src/trace/context.rs:19`). + - Reads/writes use `try_with(...)`, making scope optional from call sites (`crates/dspy-rs/src/trace/context.rs:35`, `crates/dspy-rs/src/trace/context.rs:47`, `crates/dspy-rs/src/trace/context.rs:63`). + - The file explicitly documents orphaned-task/leak caveats when scope owners outlive expected lifetime (`crates/dspy-rs/src/trace/context.rs:21`). + +2. **Current settings are process-global, not scoped per evaluation/attempt.** + - Global settings are a `LazyLock>>` singleton (`crates/dspy-rs/src/core/settings.rs:20`). + - `configure()` replaces the singleton for the whole process (`crates/dspy-rs/src/core/settings.rs:27`). + - Typed `Predict` reads LM from global settings each call, and `LegacyPredict` reads both adapter + LM from the same singleton (`crates/dspy-rs/src/predictors/predict.rs:67`, `crates/dspy-rs/src/predictors/predict.rs:582`). + - Implication: hint context should not be placed in process-global settings. + +3. **Instruction injection exists today, but as mutable predictor state, not per-call scoped state.** + - Prompt system text accepts a per-call optional override (`crates/dspy-rs/src/adapter/chat.rs:438`). + - Typed predict path currently passes `self.instruction_override` into that override channel (`crates/dspy-rs/src/predictors/predict.rs:74`). + - Optimizers mutate predictor instruction in place before evaluation (`crates/dspy-rs/src/optimizer/mipro.rs:421`, `crates/dspy-rs/src/optimizer/copro.rs:223`, `crates/dspy-rs/src/optimizer/gepa.rs:444`). + - Implication: reusing mutable instruction state for retry hints risks cross-attempt/cross-example contamination. + +4. **Evaluation paths are concurrent, so context isolation is mandatory.** + - `Module::batch()` runs item futures with `buffer_unordered` (`crates/dspy-rs/src/core/module.rs:53`). + - `Evaluator::evaluate()` also computes metrics with `buffer_unordered` (`crates/dspy-rs/src/evaluate/evaluator.rs:51`). + - GEPA candidate evaluation runs example futures via `join_all` (`crates/dspy-rs/src/optimizer/gepa.rs:263`, `crates/dspy-rs/src/optimizer/gepa.rs:274`). + - Implication: shared mutable hint channels (especially thread/process global) are high-risk; attempt-scoped isolation must hold while these futures are in flight. + +5. **Explicit-context style exists in evaluation helpers, but only as local function API, not module-wide contract.** + - Retrieval helper takes optional `context_docs` explicitly (`crates/dspy-rs/src/evaluate/feedback_helpers.rs:33`). + - This is viable ergonomically for narrow helpers, but not currently applied to the core `Module` trait. + +6. **Changing to explicit context on `Module::forward` would be a broad, invasive API shift.** + - Core `Module` trait currently accepts only `inputs` (`crates/dspy-rs/src/core/module.rs:11`). + - The call shape is used broadly (e.g., tracing example wraps `module.forward(example)` directly, `crates/dspy-rs/examples/12-tracing.rs:84`). + +7. **Design-level evidence: Facet is positioned as type metadata source, not request-scoped mutable runtime state.** + - Shaping frames Facet as the single source of truth for type metadata (`docs/specs/modules/shapes.md:24`). + - Design reference reiterates Facet-derived schema as metadata/plumbing, with runtime execution behavior owned by module flow (`docs/specs/modules/design_reference.md:31`, `docs/specs/modules/design_reference.md:39`). + - Implication: Refine hint scoping belongs in runtime execution context, not Facet metadata. + +## Mechanism Options (task-local/thread-local/explicit parameter) + Tradeoffs + +| Option | How it would work | Pros | Cons / Risks | +|---|---|---|---| +| **Task-local (`tokio::task_local!`)** | Enter scope once per Refine attempt and read hint during prompt construction | Reuses existing runtime pattern; no `Module` trait break; per-attempt isolation for async work that stays in the same task | Does not auto-inherit across `tokio::spawn` / thread hops; child tasks must receive payload explicitly and re-enter scope; detached/orphaned child tasks need strict lifetime discipline (`crates/dspy-rs/src/trace/context.rs:21`) | +| **Thread-local (`thread_local!` + `RefCell`)** | Store current hint per worker thread and clear/reset around attempts | Simple primitive in synchronous code | Async evaluation uses concurrent futures (`buffer_unordered`, `join_all`); thread affinity is not request affinity, so this is brittle for attempt isolation and easy to leak between logical flows | +| **Explicit parameter** | Pass `RefineAttemptContext` through call APIs (full `Module::forward` change or parallel path) | Fully explicit dataflow; naturally safe across spawn/thread boundaries | Broad API migration cost across trait, impls, wrappers, optimizers, and examples (`crates/dspy-rs/src/core/module.rs:11`, `crates/dspy-rs/examples/12-tracing.rs:84`) | + +## Decision + +**Deferred.** The scoping mechanism will be determined when Refine is actually built. The spike findings and tradeoff analysis are preserved below for when that happens. + +The three viable options remain `tokio::task_local!` (pragmatic, spawn footgun), explicit `Module::forward` parameter (correct, invasive), and `thread_local!` (rejected — brittle under async concurrency). + +## Original Recommendation (not adopted) + +The spike originally recommended `tokio::task_local!`: + +- **Primary:** `tokio::task_local!` attempt scope for Refine retry hints. +- **Boundary rule:** whenever execution crosses task/thread boundaries (`tokio::spawn`, threadpool work), propagate hint payload explicitly and re-enter scope in the child task. +- **Reject for now:** `thread_local!` for hint state. +- **Defer:** full explicit context parameter on `Module::forward` unless spawn-heavy usage proves task-local + boundary propagation insufficient. + +Reasoning: +- It matches an existing, working runtime idiom (`trace/context.rs`) and avoids a system-wide API break. +- It gives per-attempt isolation without reusing mutable predictor instruction state. +- It keeps the typed primary path ergonomic while making cross-task behavior explicit and testable. + +## Concrete Implementation Steps + +1. Add a new Refine context module with `tokio::task_local!` state for retry hint payload (for example, `hint`, `attempt_idx`). +2. Provide helpers: `with_refine_hint_scope(...)`, `current_refine_hint()`, and a boundary helper that re-scopes explicitly in spawned child tasks. +3. In Refine retry loop, execute each attempt future within `with_refine_hint_scope(...)` at the attempt boundary. +4. In typed `Predict` call path, compute an effective instruction by composing: + - explicit instruction override (if any), and + - scoped Refine hint (if present). +5. Pass the effective instruction through existing adapter override channel (`format_system_message_typed_with_instruction`) without mutating persistent predictor state. +6. Keep hint out of optimizer state serialization (`dump_state`/`load_state`) so retry hints do not persist. +7. Add tests: + - nested module call sees hint only inside attempt scope, + - concurrent Refine runs (`buffer_unordered`/`join_all` paths) do not leak hints across attempts, + - hint is absent outside scope, + - `tokio::spawn` child task does not inherit hint by default, + - explicit propagation helper correctly restores hint inside spawned child task. +8. Document scope boundary semantics and spawned-task caveat in Refine docs. + +## Open Risks + +- If a future module path introduces background tasks without using the boundary helper, hint visibility can silently diverge from parent attempt scope. +- If Refine evolves toward deeper cross-task orchestration, the deferred explicit-parameter design may become necessary to preserve clarity. + +## Acceptance + +S4 is complete when: + +- We can describe and justify one concrete mechanism for Refine hint scoping with source-backed tradeoffs. +- Hint injection is per-attempt scoped and does not leak across concurrent evaluations. +- Base module APIs remain unchanged for this first pass. +- Spawned-task behavior is explicitly documented, including the required explicit propagation strategy. +- All S4 questions are answered with references to current code paths. diff --git a/docs/specs/modules/spikes/S5-facet-walker-containers.md b/docs/specs/modules/spikes/S5-facet-walker-containers.md new file mode 100644 index 00000000..16544fe9 --- /dev/null +++ b/docs/specs/modules/spikes/S5-facet-walker-containers.md @@ -0,0 +1,139 @@ +# S5 Spike: Facet Walker Containers + +## Context + +S5 asks how Facet-based parameter discovery should traverse containers (`Option`, `Vec`, `HashMap`, `Box`) while still yielding optimizer-usable dotted paths and leaf handles. It is called out as high-priority in shaping/design because it blocks reliable automatic predictor discovery for non-trivial module trees. + +Relevant framing: +- `docs/specs/modules/shapes.md:63` defines F6 as recursive container traversal for predictor discovery. +- `docs/specs/modules/shapes.md:244` defines S5 scope explicitly (`Option`/`Vec`/`HashMap`/`Box`). +- `docs/specs/modules/design_reference.md:530` sketches expected walker behavior by `shape.def`. + +## Goal + +Establish a concrete first-pass container traversal strategy for S5, grounded in: +- current repo behavior (what works today vs gaps), +- Facet primitives/capabilities (NIA evidence), +- and explicit limits that affect path determinism and trait-object handling. + +## Questions + +| ID | Question | +|---|---| +| **S5-Q1** | What container traversal behavior exists today in runtime/optimizer code? | +| **S5-Q2** | Which Facet primitives are available for container traversal at shape level and value level? | +| **S5-Q3** | How should `Option>` be traversed and represented in dotted paths? | +| **S5-Q4** | How should `Vec>` and `HashMap>` traversal preserve deterministic, stable naming? | +| **S5-Q5** | What concrete limits apply to `Box` in the current stack? | +| **S5-Q6** | Which implementation shape is best for a first pass (shape-only, value-only, or hybrid)? | + +## Findings (with Evidence) + +1. Current optimizer discovery is still manual `Optimizable` recursion, not Facet walker recursion. + - `Optimizable` requires `parameters(&mut self) -> IndexMap`: `crates/dspy-rs/src/core/module.rs:84`. + - `#[derive(Optimizable)]` only includes fields tagged `#[parameter]` and recursively flattens by calling child `parameters()`; no explicit container branching logic exists in the derive: `crates/dsrs-macros/src/optim.rs:41`, `crates/dsrs-macros/src/optim.rs:50`, `crates/dsrs-macros/src/optim.rs:72`, `crates/dsrs-macros/src/optim.rs:92`. + - Existing tests cover nested struct flattening only (`a`, `b.predictor`, `p.b.predictor`), not `Option`/`Vec`/`HashMap`: `crates/dspy-rs/tests/test_optimizable.rs:39`, `crates/dspy-rs/tests/test_optimizable.rs:64`, `crates/dspy-rs/tests/test_optimizable.rs:103`. + +2. Local runtime already uses Facet value traversal primitives for `Option`/list/map/pointer conversion. + - Option/list/map read-paths are used in `to_baml_value`: `crates/bamltype/src/convert.rs:539`, `crates/bamltype/src/convert.rs:593`, `crates/bamltype/src/convert.rs:602`. + - Pointer and option are handled in `from_baml_value` with explicit recurse/unwrap behavior: `crates/bamltype/src/convert.rs:125`, `crates/bamltype/src/convert.rs:133`, `crates/bamltype/src/convert.rs:137`. + - Shape-level `Def` matching for `Option`/`List`/`Map`/`Pointer` is already used by schema building: `crates/bamltype/src/schema_builder.rs:121`, `crates/bamltype/src/schema_builder.rs:123`, `crates/bamltype/src/schema_builder.rs:127`, `crates/bamltype/src/schema_builder.rs:129`, `crates/bamltype/src/schema_builder.rs:135`. + +3. NIA evidence confirms Facet has the needed container primitives and traversal controls. + - `Facet` exposes static shape via `const SHAPE`: `facet-rs/facet/facet-core/src/lib.rs:39`, `facet-rs/facet/facet-core/src/lib.rs:46`. + - `Def` includes container/pointer variants (`Map`, `List`, `Option`, `Pointer`, `DynamicValue`): `facet-rs/facet/facet-core/src/types/def/mod.rs:24`, `facet-rs/facet/facet-core/src/types/def/mod.rs:37`, `facet-rs/facet/facet-core/src/types/def/mod.rs:47`, `facet-rs/facet/facet-core/src/types/def/mod.rs:67`, `facet-rs/facet/facet-core/src/types/def/mod.rs:75`. + - Shape walker supports deterministic DFS + skip/stop + cycle detection and explicit container steps: `facet-rs/facet/facet-path/src/walk.rs:25`, `facet-rs/facet/facet-path/src/walk.rs:49`, `facet-rs/facet/facet-path/src/walk.rs:55`, `facet-rs/facet/facet-path/src/walk.rs:63`, `facet-rs/facet/facet-path/src/walk.rs:93`, `facet-rs/facet/facet-path/src/walk.rs:105`. + - Value walker primitives exist for list/map/option/pointer access (`Peek*`): `facet-rs/facet/facet-reflect/src/peek/value.rs:15`, `facet-rs/facet/facet-reflect/src/peek/value.rs:18`, `facet-rs/facet/facet-reflect/src/peek/value.rs:49`; `facet-rs/facet/facet-reflect/src/peek/list.rs:35`, `facet-rs/facet/facet-reflect/src/peek/list.rs:54`; `facet-rs/facet/facet-reflect/src/peek/map.rs:28`, `facet-rs/facet/facet-reflect/src/peek/map.rs:41`; `facet-rs/facet/facet-reflect/src/peek/option.rs:44`; `facet-rs/facet/facet-reflect/src/peek/pointer.rs:32`. + +4. NIA evidence also shows a key limit: shape-only paths encode container structure, not runtime multiplicity. + - Shape walk emits placeholder map path steps (`MapKey(0)`, `MapValue(0)`) and option/pointer semantic steps: `facet-rs/facet/facet-path/src/walk.rs:49`, `facet-rs/facet/facet-path/src/walk.rs:55`, `facet-rs/facet/facet-path/src/walk.rs:63`, `facet-rs/facet/facet-path/src/walk.rs:93`. + - Formatted paths for maps/options use generic markers like `[key#i]`, `[value#i]`, `::Some`, not concrete runtime keys: `facet-rs/facet/facet-path/src/lib.rs:47`, `facet-rs/facet/facet-path/src/lib.rs:54`, `facet-rs/facet/facet-path/src/lib.rs:61`. + +5. Trait-object limits are hard constraints in the typed path and must be treated as explicit out-of-scope for first-pass S5 support. + - BAML derive explicitly rejects trait-object fields: `crates/bamltype-derive/src/lib.rs:418`, `crates/bamltype-derive/src/lib.rs:421`. + - UI test locks in that behavior (`Box` compile-fails): `crates/bamltype/tests/ui/trait_object.rs:5`, `crates/bamltype/tests/ui/trait_object.stderr:1`. + - Design guidance explicitly chooses associated-type modules over trait-object composition for typed workflows: `docs/specs/modules/design_reference.md:992`. + - Consequence for S5: `Box` with concrete `T` can be traversed through pointer semantics; `Box` cannot be a typed walker leaf/container and should be modeled as Layer-3 dynamic graph nodes instead. + +## Container Handling Matrix + +| Container Case | First-pass Status | Traversal Rule | Canonical Path Form | Determinism Policy | Explicit Limits | +|---|---|---|---|---|---| +| `Option>` | **Supported** | Recurse only when `Some(inner)`; emit no path for `None` | `retriever` (no `::Some`) | Stable for unchanged value/state; path appears/disappears only when option toggles `Some`/`None` | No phantom leaves for `None`; absence is expected, not error | +| `Vec>` | **Supported** | Iterate index order `0..len-1` and recurse per element | `retrievers[0]`, `retrievers[1]` | Stable if vector order/content is unchanged | Index renumbering on insert/remove/reorder is accepted churn in first pass | +| `HashMap>` | **Supported** | Iterate entries sorted by canonical key order, recurse on values | `weights['foo']` | Sort by raw string key bytes before emitting paths; do not depend on map iteration order | First pass is string-key only for canonical naming; non-string keys are out-of-scope | +| `Box>` (or concrete `Box` with traversable `T`) | **Supported** | Dereference pointer and continue traversal on concrete pointee shape | Same as inner path (for example `leaf`) | Stable under same pointee graph + cycle guard | Requires concrete type metadata (`Facet`/shape); cycle protection required | +| `Box` | **Unsupported (typed walker)** | Do not descend; report unsupported at compile/design boundary | None | N/A | Rejected by BAML derive and by typed-path design; represent this case as dynamic graph nodes (`DynModule`) instead of typed container traversal | + +## Options + Tradeoffs + +| Option | Description | Pros | Cons | +|---|---|---|---| +| **A. Shape-only walker (`walk_shape`)** | Use type-level traversal only, deriving leaf locations from `Shape` graph. | Deterministic, cycle-safe, simple to reason about at schema level. | Cannot enumerate runtime multiplicity (`Vec` length, actual map keys, `Option::None` presence). | +| **B. Value-only `Peek` recursion** | Traverse live values only (`into_option`, `into_list_like`, `into_map`, `into_pointer`). | Captures real runtime leaves and concrete dotted paths (`[i]`, `['key']`). | Needs explicit cycle guards, ordering policy, and shape-based semantics for stable naming. | +| **C. Hybrid (shape-guided + value-driven)** | Use `Shape/Def` to choose traversal protocol, then `Peek` to enumerate runtime children for multiplicity containers. | Best match for S5: deterministic protocol + real runtime coverage. | More moving parts; requires clear path canonicalization rules. | + +## Decision + +**Deferred.** Container traversal (`Option`/`Vec`/`HashMap`/`Box`) is not needed for V1 library modules — all use struct-field recursion only (ChainOfThought has `predict: Predict<...>`, ReAct has `action: Predict<...>`, BestOfN wraps `module: M`). Container traversal will be implemented when a concrete use case requires it. The spike findings and tradeoff analysis are preserved below for when that happens. + +## Original Recommendation (not adopted) + +The spike originally recommended Option C (hybrid walker): + +Rationale: +- S5 requires container *runtime* handling, not just type graph coverage. +- Local code already demonstrates both sides (`Def` matching + `Peek` traversal). +- NIA evidence shows shape-only map/list steps are intentionally abstract (`key#i`/`value#i`), so runtime enumeration is required for optimizer-meaningful handles. + +## Deterministic Path Policy (First Pass) + +1. Canonical grammar: + - ` := (. | [] | [''])*` + - `` uses Rust field names as declared in the owning struct. +2. Container representation: + - `Option` is transparent for naming; emit `x`, never `x::Some`. + - `Vec` uses zero-based decimal indices (`items[0]`, `items[1]`), no leading zeros. + - `HashMap` uses single-quoted keys with escaping (`weights['foo']`). +3. Key escaping for map paths: + - Escape `\` as `\\`. + - Escape `'` as `\'`. + - Escape control characters as `\u{HEX}`. +4. Ordering policy: + - Struct fields: declaration order. + - Lists: index order. + - Maps: sort by raw UTF-8 bytes of the original key before formatting/escaping. +5. Disallowed forms in emitted paths: + - No Facet placeholder tokens (`[key#i]`, `[value#i]`). + - No semantic tags (`::Some`, pointer-only markers). + +## Concrete Implementation Steps + +1. Introduce `named_predictors` (or equivalent) walker API that accepts a reflectable root value and returns `(path, handle)` pairs. +2. Implement a shared path formatter that enforces the deterministic path policy above (grammar, escaping, ordering assumptions). +3. Resolve predictor leaf markers from shape metadata (S2 mechanism) before descending into children. +4. Implement container traversal arms: + - `Option`: recurse only for `Some`; emit nothing for `None`. + - `List/Vec`: recurse each index in `0..len-1`. + - `Map`: collect entries, sort keys by raw key bytes, recurse on values. + - `Pointer/Box`: recurse into concrete pointee when available. +5. Add explicit unsupported handling for trait-object pointers (`Box`) with clear compile/design-time diagnostics and dynamic-graph fallback guidance. +6. Add cycle protection for pointer/self-referential graphs to avoid infinite recursion. +7. Add tests for each matrix row: positive cases (`Option`, `Vec`, `HashMap`, `Box`) and negative trait-object coverage. +8. Add compatibility shim from current `Optimizable::parameters()` callers to the new walker so optimizers can migrate incrementally. + +## Acceptance + +S5 is complete when: +- The new walker discovers predictor leaves under `Option`, `Vec`, and `HashMap` containers with deterministic path output. +- Emitted paths follow one canonical grammar (`a.b`, `items[3]`, `weights['foo']`) with required escaping. +- Paths are stable under repeated traversal of unchanged structures and never include placeholder markers (`key#i`, `value#i`) or `::Some`. +- `Option::None` is handled without panic and without emitting phantom leaves. +- Map traversal ordering is deterministic and documented (sorted by raw key bytes, not hash iteration order). +- `Box` is explicitly unsupported in typed traversal with a documented dynamic-graph alternative. +- Tests cover positive and negative container cases and are integrated with existing optimizer discovery flows. + +## Open Risks + +- Non-string map keys remain out-of-scope for first pass; adding them later will require a canonical key-rendering spec that is collision-safe. +- Vector index paths are deterministic but not identity-stable under insert/reorder; optimizers that persist paths across structure edits will need remapping logic. diff --git a/docs/specs/modules/spikes/S6-migration-fieldspec-to-signatureschema.md b/docs/specs/modules/spikes/S6-migration-fieldspec-to-signatureschema.md new file mode 100644 index 00000000..232ebd4b --- /dev/null +++ b/docs/specs/modules/spikes/S6-migration-fieldspec-to-signatureschema.md @@ -0,0 +1,145 @@ +# S6 Spike: Incremental migration from `FieldSpec`/`MetaSignature` to Facet-derived `SignatureSchema` + +## Context +The shaping docs call out S6 as the migration question for replacing static macro-emitted `FieldSpec` metadata and `MetaSignature` JSON maps with Facet-derived `SignatureSchema` (`docs/specs/modules/shapes.md:145`). + +Current runtime has both: +- Typed `Predict` path using static `FieldSpec` arrays. +- Legacy `LegacyPredict` path using `MetaSignature` trait objects and JSON field maps. + +Design docs target Facet-derived metadata as end state (`docs/specs/modules/design_reference.md:31`, `docs/specs/modules/design_reference.md:240`, `docs/specs/modules/design_reference.md:249`). + +## Goal +Identify a safe, incremental migration path that: +- Enables path-aware/flatten-aware metadata consumption for typed modules first. +- Preserves existing optimizer and legacy behaviors until replacement surfaces are ready. +- Avoids a single high-risk cutover across adapter, predictor, macro, optimizer, and examples. + +## Questions + +| # | Question | +|---|---| +| **S6-Q1** | Where is `FieldSpec` wired into typed prompt formatting/parsing today? | +| **S6-Q2** | Where is `MetaSignature` wired into legacy adapter/optimizer paths today? | +| **S6-Q3** | Which interfaces make big-bang migration high risk? | +| **S6-Q4** | Can Facet provide the metadata primitives needed for `SignatureSchema` (paths, attrs, flatten, traversal)? | +| **S6-Q5** | What compatibility seams allow incremental rollout without breaking optimizers/examples? | +| **S6-Q6** | What phased migration sequence minimizes regressions and duplicate rewrites? | + +## Findings + +1. `Signature` is currently hard-coupled to static `FieldSpec` arrays and `output_format_content`. + - Evidence: trait requires `input_fields()`, `output_fields()`, `output_format_content()` (`crates/dspy-rs/src/core/signature.rs:44`, `crates/dspy-rs/src/core/signature.rs:45`, `crates/dspy-rs/src/core/signature.rs:46`). + - `FieldSpec` is top-level and pathless (`name`, `rust_name`, `description`, `type_ir`, `constraints`, `format`) (`crates/dspy-rs/src/core/signature.rs:6`). + +2. `#[derive(Signature)]` still emits static `FieldSpec` arrays and returns them via trait methods. + - Evidence: `generate_field_specs` creates static arrays (`crates/dsrs-macros/src/lib.rs:444`, `crates/dsrs-macros/src/lib.rs:567`). + - Signature impl returns those statics (`crates/dsrs-macros/src/lib.rs:660`, `crates/dsrs-macros/src/lib.rs:664`). + - Derive helper attrs still exclude `flatten` (`crates/dsrs-macros/src/lib.rs:22`). + +3. Typed ChatAdapter path is currently top-level-key based, not path-based. + - Formatting reads input by `field_spec.rust_name` from top-level map (`crates/dspy-rs/src/adapter/chat.rs:530`, `crates/dspy-rs/src/adapter/chat.rs:531`). + - Assistant formatting uses top-level output keys (`crates/dspy-rs/src/adapter/chat.rs:555`, `crates/dspy-rs/src/adapter/chat.rs:556`). + - Parsing inserts directly into top-level `output_map` by rust field name (`crates/dspy-rs/src/adapter/chat.rs:607`, `crates/dspy-rs/src/adapter/chat.rs:720`). + +4. Typed `Predict` still implements `MetaSignature` via `FieldSpec -> serde_json::Value` conversion. + - Bridge function converts typed `FieldSpec` arrays into legacy JSON maps (`crates/dspy-rs/src/predictors/predict.rs:251`, `crates/dspy-rs/src/predictors/predict.rs:265`). + - `Predict` exposes this through `MetaSignature` impl (`crates/dspy-rs/src/predictors/predict.rs:443`, `crates/dspy-rs/src/predictors/predict.rs:473`, `crates/dspy-rs/src/predictors/predict.rs:478`). + +5. Legacy adapter/predictor surfaces are explicitly `MetaSignature`-based. + - Adapter trait is defined on `&dyn MetaSignature` (`crates/dspy-rs/src/adapter/mod.rs:15`, `crates/dspy-rs/src/adapter/mod.rs:24`). + - Legacy system prompt/user/assistant formatting uses `MetaSignature::input_fields/output_fields` JSON objects (`crates/dspy-rs/src/adapter/chat.rs:295`, `crates/dspy-rs/src/adapter/chat.rs:360`, `crates/dspy-rs/src/adapter/chat.rs:394`). + - `LegacyPredict` stores `Arc` and calls adapter with it (`crates/dspy-rs/src/predictors/predict.rs:513`, `crates/dspy-rs/src/predictors/predict.rs:587`). + +6. Optimizer APIs still assume `MetaSignature` and manual `Optimizable::parameters()` traversal. + - `Optimizable` contract exposes `get_signature() -> &dyn MetaSignature` and `parameters()` (`crates/dspy-rs/src/core/module.rs:85`, `crates/dspy-rs/src/core/module.rs:89`). + - MIPRO/COPRO read `input_fields`/`output_fields` JSON and mutate instructions through that interface (`crates/dspy-rs/src/optimizer/mipro.rs:491`, `crates/dspy-rs/src/optimizer/copro.rs:73`, `crates/dspy-rs/src/optimizer/copro.rs:223`). + - Optimizable derive is still `#[parameter]`-driven and flatten-by-recursive-calls, with unsafe pointer casts (`crates/dsrs-macros/src/optim.rs:41`, `crates/dsrs-macros/src/optim.rs:49`, `crates/dsrs-macros/src/optim.rs:72`, `crates/dsrs-macros/src/optim.rs:92`). + +7. Legacy usage is still broad in examples/tests, so immediate removal would be disruptive. + - `LegacyPredict` appears across optimizer code, examples, and tests (`crates/dspy-rs/src/optimizer/mipro.rs:289`, `crates/dspy-rs/src/optimizer/gepa.rs:321`, `crates/dspy-rs/tests/test_optimizable.rs:1`). + - `MetaSignature` is directly used in adapter and optimizer tests (`crates/dspy-rs/tests/test_adapters.rs:8`, `crates/dspy-rs/tests/test_miprov2.rs:455`). + +8. Existing spike docs already identify path-aware metadata as migration-critical and suggest phased compatibility. + - S1: path-aware runtime metadata is needed; macro-only change is insufficient (`docs/specs/modules/spikes/S1-generic-signature-derive.md:35`, `docs/specs/modules/spikes/S1-generic-signature-derive.md:101`, `docs/specs/modules/spikes/S1-generic-signature-derive.md:110`). + - S3: composed augmentation rollout is blocked by top-level `FieldSpec` model (`docs/specs/modules/spikes/S3-augmentation-deref-composition.md:41`, `docs/specs/modules/spikes/S3-augmentation-deref-composition.md:84`). + - S2: optimizer migration needs compatibility shims (`docs/specs/modules/spikes/S2-dynpredictor-handle-discovery.md:26`, `docs/specs/modules/spikes/S2-dynpredictor-handle-discovery.md:94`, `docs/specs/modules/spikes/S2-dynpredictor-handle-discovery.md:111`). + +9. Facet-backed primitives needed for `SignatureSchema` are already exercised in this workspace, but flatten semantics still need local migration tests. + - Facet is the schema substrate for `BamlSchema` (`.workspaces/facet/crates/bamltype/src/lib.rs:87`). + - Runtime schema construction already walks `Shape`/`Def` variants (`Option`, `List`, `Map`) and field/type attrs (`.workspaces/facet/crates/bamltype/src/schema_builder.rs:8`, `.workspaces/facet/crates/bamltype/src/schema_builder.rs:113`, `.workspaces/facet/crates/bamltype/src/schema_builder.rs:123`, `.workspaces/facet/crates/bamltype/src/schema_builder.rs:127`, `.workspaces/facet/crates/bamltype/src/schema_builder.rs:129`, `.workspaces/facet/crates/bamltype/src/schema_builder.rs:622`). + - Typed attribute payload extraction is in active use via `Attr::get_as` and local attr grammar (`.workspaces/facet/crates/bamltype/src/facet_ext.rs:37`, `.workspaces/facet/crates/bamltype/src/facet_ext.rs:43`, `.workspaces/facet/crates/bamltype/src/facet_ext.rs:55`). + - Reflect conversion already recurses through containers using `Partial`/`Peek` (`.workspaces/facet/crates/bamltype/src/convert.rs:4`, `.workspaces/facet/crates/bamltype/src/convert.rs:117`, `.workspaces/facet/crates/bamltype/src/convert.rs:132`, `.workspaces/facet/crates/bamltype/src/convert.rs:191`, `.workspaces/facet/crates/bamltype/src/convert.rs:215`, `.workspaces/facet/crates/bamltype/src/convert.rs:522`, `.workspaces/facet/crates/bamltype/src/convert.rs:593`, `.workspaces/facet/crates/bamltype/src/convert.rs:602`). + - Flatten-specific behavior is still migration-sensitive because current typed adapter path remains top-level-key based (Finding 3), so flatten support must be proven by parity tests, not assumed. + +## Migration options + +| Option | Approach | Pros | Risks | +|---|---|---|---| +| **A. Big-bang cutover** | Replace `FieldSpec` + `MetaSignature` + `LegacyPredict` in one release. | Clean architecture quickly. | High break risk across adapter, optimizers, examples, and tests; hard rollback boundary. | +| **B. Typed-path first, compatibility bridge** | Introduce `SignatureSchema` for typed path; keep legacy `MetaSignature` path and provide conversion shims during transition. | Unblocks flatten/path-aware typed work (S1/S3) with controlled blast radius. | Temporary dual surfaces and conversion code. | +| **C. Optimizer-first replacement** | Replace `Optimizable`/`MetaSignature` discovery before typed adapter migration. | Early investment in long-term optimizer model. | Delays typed schema wins; still blocked by pathless typed adapter metadata. | + +## Decision + +**Subsumed by S1 → Option C.** There is no incremental migration. `SignatureSchema` is built from Facet, `FieldSpec`/`MetaSignature`/`LegacyPredict` are deleted. The 4-phase plan below is preserved as reference for understanding the dependency surface, but it will not be executed as phased work. + +## Original Recommendation (not adopted) + +The spike originally recommended Option B in four gated phases. Each phase has a compatibility contract and explicit rollback lever. + +### Phase plan (tightened) + +| Phase | Scope change | Compatibility points that must remain true | Rollback control | Exit gate | +|---|---|---|---|---| +| **Phase 1: Introduce typed `SignatureSchema` (internal only)** | Add `SignatureSchema`/`FieldPath` model and cache, but do not switch typed call sites yet. | `Signature` trait API stays unchanged (`input_fields`, `output_fields`, `output_format_content`); all typed and legacy tests remain green with old adapter path. | Keep schema read-path behind internal feature gate; immediate fallback is old `FieldSpec` readers only. | Schema parity snapshots for non-flatten signatures match current `FieldSpec`/output-format behavior. | +| **Phase 2: Cut typed adapter to path-aware schema reads/writes** | Switch `format_*_typed` + `parse_response_typed` to schema path traversal and path insertion. | Legacy adapter remains `&dyn MetaSignature`; `Predict: MetaSignature` bridge remains unchanged for optimizer callers; typed prompt text remains byte-for-byte stable for non-flatten signatures. | Dual-path adapter switch (`typed_adapter_v1`/`typed_adapter_v2`) with runtime/compile-time kill switch. | Typed parity tests pass for flat signatures and new path tests pass for nested/flatten-shaped fixtures. | +| **Phase 3: Migrate derive to Facet-shape schema generation** | Extend derive for `flatten`, and generate schema from `S::Input`/`S::Output` Facet shape metadata. | Temporary dual-emission: keep old `FieldSpec` outputs for legacy call sites while typed adapter consumes `SignatureSchema`; deterministic field ordering and alias/constraint/format parity preserved. | Keep macro fallback mode that emits legacy-only `FieldSpec` path if schema generation fails in release branch. | Generated-schema coverage includes flatten + generic signatures from S1/S3 fixtures; no typed regressions. | +| **Phase 4: Migrate optimizers/discovery and retire legacy path** | Introduce schema-backed optimizer handles/discovery (S2/S5), migrate COPRO/MIPRO/GEPA incrementally, then remove legacy surfaces. | During migration, optimizer entry points can consume both old (`MetaSignature`/`Optimizable`) and new schema-backed handles; `LegacyPredict` remains available until final removal checkpoint. | Per-optimizer rollback: keep old entrypoint active until each optimizer passes parity suite; no cross-optimizer big-bang switch. | All optimizers/examples/tests run without `MetaSignature`/`LegacyPredict`; removal checklist completed and legacy shims deleted in one final cleanup PR. | + +### Compatibility checkpoints (cross-phase) + +1. Keep legacy adapter interface (`Adapter::format`/`parse`) stable until Phase 4 is complete (`crates/dspy-rs/src/adapter/mod.rs:15`, `crates/dspy-rs/src/adapter/mod.rs:24`). +2. Preserve typed-to-legacy bridge while optimizer code depends on `MetaSignature` (`crates/dspy-rs/src/predictors/predict.rs:443`, `crates/dspy-rs/src/predictors/predict.rs:473`, `crates/dspy-rs/src/predictors/predict.rs:478`). +3. Treat optimizer migration as N small cutovers (COPRO, MIPRO, GEPA) instead of one global switch (`crates/dspy-rs/src/optimizer/copro.rs:73`, `crates/dspy-rs/src/optimizer/mipro.rs:491`, `crates/dspy-rs/src/optimizer/gepa.rs:321`). +4. Keep examples/tests as canaries during dual-path operation (`crates/dspy-rs/tests/test_adapters.rs:8`, `crates/dspy-rs/tests/test_miprov2.rs:455`, `crates/dspy-rs/tests/test_optimizable.rs:1`). + +### Rollback risk controls + +1. **Dual-path toggles**: keep old and new typed adapter paths side-by-side through Phase 2. +2. **Dual-emission window**: keep derive outputting legacy `FieldSpec` metadata during Phase 3 while schema path stabilizes. +3. **Per-surface canaries**: require parity test suites for typed adapter, derive, and each optimizer before advancing phase gate. +4. **No irreversible deletions before Phase 4 exit**: do not remove `MetaSignature`/`LegacyPredict` until all consumer references are gone. +5. **Branch-safe fallback**: each phase merge must be revertible without data migration scripts or serialized-state rewrites. + +## Concrete implementation steps + +1. Add `signature_schema.rs` in runtime core with `SignatureSchema`, `FieldSchema`, and `FieldPath`. +2. Add `SignatureSchema::of::()` cache entrypoint and parity adapter that can project schema fields back to legacy `FieldSpec`-shaped JSON. +3. Introduce typed adapter path helpers: `read_at_path` and `insert_at_path` for nested read/write. +4. Add dual-path typed adapter switch and parity tests asserting non-flatten prompt/parse equivalence against current behavior. +5. Extend `#[derive(Signature)]` parser to accept `flatten` and preserve field ordering, alias, constraints, and format data. +6. Add schema-generation path in derive from `S::Input`/`S::Output` Facet metadata, while retaining temporary `FieldSpec` emission. +7. Add focused migration tests: + - flat signature parity, + - flatten roundtrip (input + output), + - alias/constraint/format parity, + - deterministic ordering across rebuilds. +8. Add optimizer compatibility shim so current `MetaSignature` consumers and new schema-backed handles can coexist. +9. Migrate COPRO first, then MIPRO, then GEPA, keeping per-optimizer fallback entrypoints until parity passes. +10. Remove legacy surfaces only after a final zero-reference check for `MetaSignature`, `LegacySignature`, and `LegacyPredict`. + +## Acceptance +S6 is complete when: + +- All S6 questions are answered with code/doc evidence. +- Typed adapter/predict path can operate from `SignatureSchema` (including path-aware parse/format) with parity for non-flatten signatures and explicit coverage for flatten signatures. +- Legacy/optimizer flows continue functioning during transition via compatibility shims, with per-optimizer cutover evidence. +- Every phase exit gate and rollback lever in this document has a passing validation artifact (test/snapshot/checklist item). +- A final cleanup checkpoint removes `FieldSpec`/`MetaSignature`/`LegacyPredict` only after zero-reference and parity checks pass. + +## Open Risks + +1. **S2/S5 are still hard blockers for full optimizer migration**: handle extraction + container discovery details are not finalized (`docs/specs/modules/spikes/S2-dynpredictor-handle-discovery.md:26`). +2. **Flatten rollout risk remains concentrated in Phase 3**: current typed adapter is still top-level-key based until Phase 2 lands (`crates/dspy-rs/src/adapter/chat.rs:530`, `crates/dspy-rs/src/adapter/chat.rs:607`). +3. **Dual-path drift risk**: temporary coexistence of schema path + legacy path can diverge if parity tests are not required at every phase gate. diff --git a/docs/specs/modules/spikes/S7-augmentation-derive-feasibility.md b/docs/specs/modules/spikes/S7-augmentation-derive-feasibility.md new file mode 100644 index 00000000..b30345d6 --- /dev/null +++ b/docs/specs/modules/spikes/S7-augmentation-derive-feasibility.md @@ -0,0 +1,78 @@ +# S7 Spike: `#[derive(Augmentation)]` Feasibility + `Augmented` Phantom Type + +## Context + +F3 specifies `#[derive(Augmentation)]` which generates a generic wrapper type from a non-generic input struct, and `Augmented` as a phantom type-level combinator. Neither was verified against the existing derive infrastructure. The design reference has a literal `todo!()` in `Augmented::from_parts`. + +## Goal + +Determine if `#[derive(Augmentation)]` is feasible given existing Facet/BamlType derive capabilities, and resolve the `Augmented` phantom type design. + +## Questions + +| ID | Question | +|---|---| +| **S7-Q1** | Can a proc macro generate a generic struct (`WithReasoning`) from a non-generic input (`Reasoning`)? | +| **S7-Q2** | Do Facet and BamlType derives handle generic types with trait bounds? | +| **S7-Q3** | Does anything in the call pipeline actually call `from_parts`/`into_parts` on a Signature? | +| **S7-Q4** | What is the correct design for `Augmented` given the phantom type problem? | + +## Findings + +1. **Generating a generic struct from non-generic input is standard proc macro practice.** The macro receives a token stream and can emit any valid Rust. The `#[derive(Signature)]` macro already generates new structs (`{Name}Input`, `__{Name}Output`) from the input. Adding a generic parameter is a small incremental change. + +2. **Facet derive handles generics.** Evidence from `facet-macros-impl/src/derive.rs:46-66` and `process_struct.rs:1459-1471`: it detects `has_type_or_const_generics`, uses `TypeOpsIndirect` for generic types, and generates proper `where` clauses via `build_where_clauses()` that automatically adds `T: Facet<'f>` bounds. It skips static SHAPE generation for generic types (can't monomorphize a static), but `::SHAPE` works at call sites. + +3. **BamlType derive handles generics.** `bamltype-derive/src/lib.rs:139` already calls `input.generics.split_for_impl()` and threads `impl_generics`, `ty_generics`, `where_clause` through generated code. `OnceLock` statics work per-monomorphization — each concrete `WithReasoning` gets its own cache entry. + +4. **`from_parts`/`into_parts` ARE called in 5 places:** + + | Location | Method | Purpose | + |----------|--------|---------| + | `predict.rs:179` | `S::from_parts(input, typed_output)` | Reassemble full signature struct from input + parsed output | + | `predict.rs:332` | `S::from_parts(input, output)` | Construct typed signature from untyped Example | + | `predict.rs:340` | `signature.into_parts()` | Convert typed signature to untyped Example | + | `predict.rs:410` | `call_result.output.into_parts()` | Extract output for Module::forward | + | `chat.rs:575` | `demo.into_parts()` | Split demo into input + output for formatting | + +5. **All 5 call sites exist to support the pattern of combining input+output into one struct, then splitting back apart.** The design reference already calls for demos to be `Vec>` (typed input + output pairs, not `Vec`). If Predict stores pairs and returns `Output` directly, the round-trip is unnecessary. + +## Decision + +**Remove `from_parts`/`into_parts` from the `Signature` trait.** + +The new trait: +```rust +pub trait Signature: Send + Sync + 'static { + type Input: BamlType + Facet + Send + Sync; + type Output: BamlType + Facet + Send + Sync; + fn instructions() -> &'static str { "" } +} +``` + +`Augmented` becomes a clean type-level combinator: +```rust +pub struct Augmented(PhantomData<(S, A)>); + +impl Signature for Augmented { + type Input = S::Input; + type Output = A::Wrap; + fn instructions() -> &'static str { S::instructions() } +} +``` + +No `todo!()`, no `unreachable!()`. + +The user's `#[derive(Signature)]` still generates the combined struct with all fields (for ergonomic `result.question` / `result.answer` access), but that's a convenience on the user's type, not a requirement of the trait. + +Downstream changes: +- `Predict::call()` returns `S::Output` directly +- Demos stored as `Demo { input: S::Input, output: S::Output }` +- `format_demo_typed` takes `(&S::Input, &S::Output)` instead of `&S` + +## Acceptance + +S7 is complete when: +- We can confirm all three derive macros (Facet, BamlType, Augmentation) support generating/handling generic types +- The `Augmented` phantom type works without `from_parts`/`into_parts` +- The `Signature` trait simplification is reflected in the design reference diff --git a/docs/specs/modules/spikes/S8-facet-flatten-metadata.md b/docs/specs/modules/spikes/S8-facet-flatten-metadata.md new file mode 100644 index 00000000..7a200ef1 --- /dev/null +++ b/docs/specs/modules/spikes/S8-facet-flatten-metadata.md @@ -0,0 +1,75 @@ +# S8 Spike: Facet Flatten Metadata Behavior + +## Context + +The entire SignatureSchema + adapter design hinges on detecting `#[facet(flatten)]` on struct fields and recursing into the inner type's fields. This was assumed but never verified against Facet's actual API. + +## Goal + +Determine exactly how `#[facet(flatten)]` manifests in Shape metadata and how to write a flatten-aware field walker for SignatureSchema derivation. + +## Questions + +| ID | Question | +|---|---| +| **S8-Q1** | When a struct has `#[facet(flatten)]` on a field, does the parent's StructType still show that field, or does Facet inline the inner fields? | +| **S8-Q2** | How do you detect a field is flattened? Attr? Flag? | +| **S8-Q3** | What does `field.shape()` return for a flattened field? | +| **S8-Q4** | Can we write a concrete walk that produces a flat field list from nested flatten? | + +## Findings + +1. **The field is still present in `StructType.fields`.** Facet does NOT inline inner fields at compile time. The derive macro sets `FieldFlags::FLATTEN` bit on the field (`facet-macros-impl/src/process_struct.rs:741`). The parent's `StructType.fields` slice has all declared fields, including the flattened one. + +2. **Detection: `field.is_flattened()` — O(1) flag check.** `FieldFlags::FLATTEN = 1 << 1` (`facet-core/src/types/ty/field.rs:58`). Convenience method at `field.rs:169-175`: + ```rust + pub const fn is_flattened(&self) -> bool { + self.flags.contains(FieldFlags::FLATTEN) + } + ``` + +3. **`field.shape()` returns the inner type's Shape.** For `#[facet(flatten)] inner: O`, `field.shape()` returns `O::SHAPE`. The flatten flag doesn't change what shape is stored — it's always the declared field type's shape. + +4. **Facet already ships a flatten-aware iterator.** `fields_for_serialize()` in `facet-reflect/src/peek/fields.rs:452-626` uses a stack-based iterator that checks `is_flattened()` and recurses into inner struct fields. For schema-time (no values), the walk is: + + ```rust + fn walk_fields_flat(shape: &'static Shape) -> Vec<&'static Field> { + let Type::User(UserType::Struct(st)) = &shape.ty else { return vec![] }; + let mut result = Vec::new(); + for field in st.fields.iter() { + if field.is_flattened() { + result.extend(walk_fields_flat(field.shape())); + } else { + result.push(field); + } + } + result + } + ``` + + For `WithReasoning { reasoning, #[flatten] inner: QAOutput { answer } }`, this yields `[reasoning, answer]`. + +5. **Current bamltype code gaps:** + - `schema_builder.rs` `build_struct_ir()`: iterates fields without checking `is_flattened()` — needs update + - `convert.rs` deserialization (`build_object_fields`): no flatten awareness — needs update + - `convert.rs` serialization: already works via Facet's `fields_for_serialize()` + +## Decision + +**Facet flatten works exactly as the design assumes.** The API is: +- `field.is_flattened()` to detect +- `field.shape()` to get inner type's Shape +- Recurse to produce flat field list + +`SignatureSchema::derive()` will use this pattern in its `walk_fields` function. The design reference's pseudocode (`has_dsrs_flatten(field)`) maps directly to `field.is_flattened()`. + +Known work when implementing: +- `schema_builder.rs` needs flatten-aware `build_struct_ir()` +- `convert.rs` deserialization needs flatten-aware `build_object_fields()` + +## Acceptance + +S8 is complete when: +- Facet's flatten API is documented with concrete code evidence +- The mapping from design pseudocode to real Facet API is established +- Gaps in bamltype code are identified diff --git a/docs/specs/what_facet_has.md b/docs/specs/what_facet_has.md new file mode 100644 index 00000000..9acb7b1c --- /dev/null +++ b/docs/specs/what_facet_has.md @@ -0,0 +1,307 @@ +Sure — I’m going to treat “primitives” as: **the core building blocks facet gives you to describe types (metadata) and to work with values (runtime reflection / construction)**. + +Facet is essentially two layers: + +1. **Type-level reflection** (static metadata): `Shape`, `Type`, `Def`, `Field`, `Attr`, etc. +2. **Value-level reflection** (runtime operations over unknown types): `Peek`, `Poke`, `Partial`, `HeapValue`, etc. + +That split is important because it maps cleanly onto your “DSPy-in-Rust” needs: + +* **Module/signature/prompt building** = mostly type-level (Shapes) +* **Parsing/assembling outputs** = value-level (Partial / Peek) + +--- + +## 1) `Shape`: the “root primitive” (type metadata graph) + +**`Shape` is the hub object.** Everything else hangs off it, and every “facet-enabled” type has (conceptually) a single static `Shape` you can walk. + +A `Shape` carries enough information to answer questions like: + +* “What kind of thing is this?” (struct/enum/scalar/container/etc) +* “What are its fields / variants?” +* “What are the docs / attributes attached to it?” +* “What is it parameterized by?” (type params / lifetimes) +* “How do I allocate / drop / traverse it safely?” (via reflect support, vtables/layout) + +In other words: **`Shape` is your schema node**; walking Shapes gives you a **type graph** that you can turn into: + +* a prompt schema (“output fields are: …”) +* a BAML-ish IR +* a generic serializer/deserializer +* an optimizer-visible signature model + +(You can see `Shape`’s role + its associated metadata in the `Shape` API/docs.) ([Docs.rs][1]) + +--- + +## 2) `Type` vs `Def`: the two “classification primitives” + +Facet intentionally splits classification into two knobs: + +### 2.1 `Type`: “What category of Rust type is this?” + +`Type` is the *high-level category*: + +* **Pointer** +* **Primitive** +* **Sequence** +* **User** +* **Undefined** ([Docs.rs][2]) + +This is useful for questions like: + +* “Is this a user-defined struct/enum?” +* “Is this some pointer/reference-ish thing?” +* “Is this a container-like sequence type?” + +### 2.2 `Def`: “What is the structural/data-model definition of this value?” + +`Def` is the *structural definition* / “shape of the data” (facet’s own internal “data model” categories). Its variants include: + +* Scalar +* Option +* List +* Map +* Array +* Set +* Union +* Struct +* Enum +* Bytes +* Slice +* Pointer +* Result +* DynamicValue +* NdArray +* Undefined ([Docs.rs][3]) + +This is the thing you match on for questions like: + +* “Do I iterate fields? variants? list items? key/value pairs?” +* “Does parsing need `begin_field` vs `begin_list_item` vs `begin_key`?” +* “Is this optional? a result? a bytes blob?” + +### Why you should care about both + +In practice (for frameworks like yours): + +* `Type` helps you decide **“is this primitive vs user type vs pointer”** at a coarse level. +* `Def` helps you decide **“what traversal / construction protocol do I use”**. + +Think: + +* **`Def` = traversal protocol** +* **`Type` = category / semantic bucket** + +--- + +## 3) Primitive *scalar* typing: `PrimitiveType` (+ Numeric/Textual subtypes) + +Facet’s scalar primitive typing is modeled as: + +* **Boolean** +* **Numeric(NumericType)** +* **Textual(TextualType)** +* **Never** ([Docs.rs][4]) + +This is deliberately “schema-ish” rather than “Rust-specific”: + +* In your IR, you might collapse many `NumericType`s into a single “number” (or split ints/floats). +* `TextualType` is the “string-like” bucket. +* “Never” covers `!`-like bottom types (mostly matters for completeness). + +Also important: some “things you might call primitives” are modeled as **`Def` variants** instead of `PrimitiveType`: + +* e.g. **Bytes** is in `Def` (so you can treat it specially if you want). ([Docs.rs][3]) + +--- + +## 4) User-defined data: `StructType`, `EnumType`, `Field`, `Variant` + +When `Type` indicates a user type (and/or `Def` indicates `Struct`/`Enum`), you drop into the user-type metadata. + +### 4.1 `Field`: the *struct field primitive* + +A **`Field`** gives you the per-field metadata you need to build signatures/prompts: + +* field name (and *effective* name) +* rename behavior +* attached attributes +* doc comments +* access to the field’s own type shape +* layout/offset-related info (used by the runtime reflection layer) +* helper predicates around “skip”, defaults, etc. ([Docs.rs][5]) + +Why this matters for you: + +* **DSPy-style field specs** become: + “walk `StructType` → iterate `Field`s → read `Field` name + docs + field type Shape” +* “skip/rename/default” becomes standardized metadata you can consistently interpret. + +### 4.2 `Attr`: the *attribute primitive* + +An **`Attr`** is facet’s unit of annotation metadata. It’s designed to be: + +* *namespaced* (so multiple frameworks can coexist) +* *typed* (you can decode into a Rust type) +* attachable at different levels (type/field/variant, etc) + +The `Attr` API includes: + +* namespace/key metadata +* helpers like “is this builtin?” +* a typed decode path (e.g. `get_as()`) ([Docs.rs][6]) + +For your system, this is the critical hook for: + +* BAML-specific annotations: descriptions, aliases, “skip in schema”, “treat as string”, etc. +* carrying optimizer hints +* carrying “prompt rendering policy” hints + +### 4.3 Enums and variants + +Facet also has an explicit notion of enums (via `Def::Enum`) and variant metadata (a `Variant` type exists in facet). ([Docs.rs][3]) + +Even if you don’t use advanced enum tagging at first, the important conceptual primitive is: + +* **an enum Shape carries a set of variant descriptors** +* each variant may have payload structure (unit/newtype/struct-like fields), docs, attrs + +That’s enough to: + +* render enum schema in a prompt +* parse model output into the correct variant dynamically + +--- + +## 5) Runtime/value reflection primitives (what makes facet “framework-powering”) + +Everything above is “metadata.” The next tier is: **using that metadata to read/build values you don’t know at compile time**. + +This is where your earlier “Partial builder API” idea is coming from — and facet *does* provide exactly that conceptually. + +### 5.1 `Peek`: read-only, type-erased value view + +**`Peek` is a read-only handle to “some value + its Shape.”** +It’s type-erased, so you can write generic logic like: + +* “if it’s a struct, iterate its fields and peek each field” +* “if it’s a list, iterate elements” +* “if it’s an option, check none/some” + +That “generic traversal” is how you build: + +* debug printers +* generic serializers +* “extract field X by name” logic +* interpreters that don’t want `T: Deserialize` + +(The `Peek` API is documented in facet-reflect.) + +### 5.2 `Poke`: mutable, type-erased value view + +**`Poke` is the mutable counterpart to `Peek`** (same idea: “value + Shape”, but mutable). It exists as part of the facet-reflect surface (alongside the other runtime primitives). ([Docs.rs][7]) + +You likely care about this less for LLM parsing, but it matters if: + +* you want “patching” / in-place edits (e.g., post-parse normalization) +* you want generic mutation utilities + +### 5.3 `Partial`: the *construction primitive* (type-erased builder) + +This is the big one for your “parse LM output into arbitrary user-defined augmentation structs” plan. + +Facet-reflect’s **`Partial`** is: + +* **type-erased** +* **heap-allocated** +* **partially-initialized** +* explicitly tracks initialization state for nested structures + +Key behaviors (very relevant to you): + +* You **allocate** a `Partial` for a type/shape (`alloc`, `alloc_shape`, and “owned” variants exist). ([Docs.rs][8]) +* You **navigate down** into nested structure using “begin_*” methods such as `begin_field`, `begin_list_item`, `begin_key`, etc. (there are many). ([Docs.rs][8]) +* You **set** values via `set`/`set_value`-style calls. +* You must **end** frames with `end`. +* If you `end` a frame but it isn’t fully initialized, that’s an error; and you can’t “go back down” into the same field after popping it — it forces a consistent one-pass construction discipline. ([Docs.rs][8]) + +This is exactly the primitive you want for: + +* parse `[[ ## field ## ]] ...` blocks into arbitrary structs `A` +* without knowing `A` at compile time (beyond having its `Shape`) + +### 5.4 Owned dynamic values: `HeapValue` / “owned peeks” + +Facet-reflect also provides “owned, type-erased value storage” primitives (e.g. `HeapValue`) so you can hold onto a value + shape without borrowing from some external buffer. ([Docs.rs][7]) + +This becomes relevant if: + +* you want to store parsed outputs in caches +* you want to move them across async boundaries without tying lifetimes to an input buffer + +--- + +## 6) A practical “primitive map” for *your* architecture + +Here’s how the primitives line up with what you’re building: + +### Building a DSPy-like “signature schema” in Rust + +Use: + +* `&'static Shape` to anchor the type +* `Type` / `Def` to decide traversal +* `Field` (names/docs/attrs) for struct outputs +* `PrimitiveType` for leaf typing +* `Attr` + docs for descriptions / schema directives ([Docs.rs][2]) + +### Parsing LLM output into “unknown but facet-enabled types” + +Use: + +* `Partial` to allocate + progressively build values +* `Def` to drive which `begin_*` protocol you apply at each node +* (Optionally) `Peek` afterward for validation/normalization passes ([Docs.rs][8]) + +### Custom user augmentation types “just work” + +That’s basically: + +* user defines a struct +* derive/enable facet (directly or via your `#[BamlType]`) +* you introspect `A::Shape` and build the prompt + parser automatically + +No hard-coded field arrays required — because `Field` + docs + attrs are the schema surface. ([Docs.rs][5]) + +--- + +## 7) What I would consider the “minimum set” of facet primitives you should internalize + +If you only memorize 10 things, make it these: + +1. `Shape` = schema node / type graph node ([Docs.rs][1]) +2. `Type` = high-level category (primitive/user/sequence/pointer/undefined) ([Docs.rs][2]) +3. `Def` = structural traversal protocol (option/list/map/struct/enum/…) ([Docs.rs][3]) +4. `PrimitiveType` = scalar leaves (bool/number/text/never) ([Docs.rs][4]) +5. `Field` = name + docs + attrs + field type shape (struct outputs) ([Docs.rs][5]) +6. `Attr` = typed, namespaced annotations ([Docs.rs][6]) +7. `Peek` = read-only “value + shape” traversal +8. `Poke` = mutable “value + shape” traversal ([Docs.rs][7]) +9. `Partial` = type-erased, progressive construction (your parsing workhorse) ([Docs.rs][8]) +10. “owned dynamic storage” (`HeapValue`, owned peeks) for lifetime-free value ownership ([Docs.rs][7]) + +--- + +If you want, next I can translate this into a **concrete “facet → BAML IR” mapping** (exactly how I’d convert `Shape/Def/Type/Field/Attr` into your internal `TypeIR` / `FieldSpec` / “signature” objects), *including* the recursion rules for list/map/option/enum and where `Partial` slots into parsing. + +[1]: https://docs.rs/facet/latest/facet/struct.Shape.html "https://docs.rs/facet/latest/facet/struct.Shape.html" +[2]: https://docs.rs/facet/latest/facet/enum.Type.html "https://docs.rs/facet/latest/facet/enum.Type.html" +[3]: https://docs.rs/facet/latest/facet/enum.Def.html "https://docs.rs/facet/latest/facet/enum.Def.html" +[4]: https://docs.rs/facet/latest/facet/enum.PrimitiveType.html "https://docs.rs/facet/latest/facet/enum.PrimitiveType.html" +[5]: https://docs.rs/facet/latest/facet/struct.Field.html "https://docs.rs/facet/latest/facet/struct.Field.html" +[6]: https://docs.rs/facet/latest/facet/struct.Attr.html "https://docs.rs/facet/latest/facet/struct.Attr.html" +[7]: https://docs.rs/facet-reflect/latest/facet_reflect/all.html "https://docs.rs/facet-reflect/latest/facet_reflect/all.html" +[8]: https://docs.rs/facet-reflect/latest/facet_reflect/struct.Partial.html "https://docs.rs/facet-reflect/latest/facet_reflect/struct.Partial.html" +