Skip to content

Commit ca52651

Browse files
hyperpolymathclaude
andcommitted
feat(verisim-api): Phase 1 Step 2 — ?include=types on octad GET
Add a per-request include-flag mechanism on GET /octads/{id} so callers can opt into the entity's declared semantic types without changing the default response shape. - new IncludeFlags struct (parses comma-separated `?include=…`) - new OctadGetQuery on the GET handler - OctadResponse.semantic_types: Option<Vec<String>>, populated only when ?include=types is set (None otherwise — legacy clients see no change) - OctadResponse::with_includes(&Octad, IncludeFlags) builder; the existing From<&Octad> impl forwards with default flags so list/POST paths are unchanged Closes the semantic-shape gap from email-octad-experiment 2026-04-27 veridicality run (genesis run scored 0.50 cap on semantic because the status response surfaced has_semantic but not the type list itself). `embedding` token is parsed-but-unused for now — Task #12 wires it up in Phase 1 Step 3 for Vector byte-exact round-trip. Tests: test_include_types_round_trip (HTTP layer — verifies default omits the field, ?include=types surfaces declaration order). 68/68 verisim-api tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ac02abb commit ca52651

1 file changed

Lines changed: 167 additions & 1 deletion

File tree

  • verisimdb/rust-core/verisim-api/src

verisimdb/rust-core/verisim-api/src/lib.rs

Lines changed: 167 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,50 @@ pub struct OctadResponse {
394394
pub has_spatial: bool,
395395
pub version_count: u64,
396396
pub provenance_chain_length: u64,
397+
/// Populated only when the caller passes `?include=types` on a GET. Listed
398+
/// in the order the entity declares them. Cheap to surface but opt-in so
399+
/// the default response shape stays small.
400+
#[serde(default, skip_serializing_if = "Option::is_none")]
401+
pub semantic_types: Option<Vec<String>>,
402+
}
403+
404+
/// Per-request flags controlling which expensive or large fields the GET
405+
/// /octads/{id} response should include. Driven by `?include=…` (a
406+
/// comma-separated list).
407+
#[derive(Debug, Default, Clone, Copy)]
408+
pub struct IncludeFlags {
409+
/// Surface `semantic_types` (the entity's declared type IRIs)
410+
pub types: bool,
411+
/// Surface the stored vector bytes (Phase 1 Step 3 — Task #12)
412+
pub embedding: bool,
413+
}
414+
415+
impl IncludeFlags {
416+
/// Parse a `?include=` value (comma-separated). Unknown tokens are ignored
417+
/// rather than errored — the API's forward-compatibility contract is that
418+
/// a future client may request includes the current server doesn't know
419+
/// about.
420+
pub fn parse(raw: Option<&str>) -> Self {
421+
let mut flags = Self::default();
422+
if let Some(s) = raw {
423+
for token in s.split(',').map(|t| t.trim()).filter(|t| !t.is_empty()) {
424+
match token {
425+
"types" => flags.types = true,
426+
"embedding" => flags.embedding = true,
427+
_ => {}
428+
}
429+
}
430+
}
431+
flags
432+
}
433+
}
434+
435+
/// Query parameters for `GET /octads/{id}`
436+
#[derive(Debug, Deserialize)]
437+
pub struct OctadGetQuery {
438+
/// Comma-separated list of optional includes — currently `types`,
439+
/// `embedding`. Unknown tokens are ignored.
440+
pub include: Option<String>,
397441
}
398442

399443
/// Status response
@@ -411,6 +455,24 @@ pub struct OctadStatusResponse {
411455

412456
impl From<&verisim_octad::Octad> for OctadResponse {
413457
fn from(h: &verisim_octad::Octad) -> Self {
458+
Self::with_includes(h, IncludeFlags::default())
459+
}
460+
}
461+
462+
impl OctadResponse {
463+
/// Build a response from an octad, honouring per-request include flags
464+
/// for opt-in fields (semantic types, vector bytes, …).
465+
pub fn with_includes(h: &verisim_octad::Octad, flags: IncludeFlags) -> Self {
466+
let semantic_types = if flags.types {
467+
Some(
468+
h.semantic
469+
.as_ref()
470+
.map(|s| s.types.clone())
471+
.unwrap_or_default(),
472+
)
473+
} else {
474+
None
475+
};
414476
Self {
415477
id: h.id.to_string(),
416478
status: OctadStatusResponse {
@@ -428,6 +490,7 @@ impl From<&verisim_octad::Octad> for OctadResponse {
428490
has_spatial: h.spatial_data.is_some(),
429491
version_count: h.version_count,
430492
provenance_chain_length: h.provenance_chain_length,
493+
semantic_types,
431494
}
432495
}
433496
}
@@ -901,6 +964,7 @@ async fn create_octad_handler(
901964
async fn get_octad_handler(
902965
State(state): State<AppState>,
903966
Path(id): Path<String>,
967+
Query(query): Query<OctadGetQuery>,
904968
) -> Result<Json<OctadResponse>, ApiError> {
905969
validate_octad_id(&id)?;
906970
let octad_id = OctadId::new(&id);
@@ -912,7 +976,8 @@ async fn get_octad_handler(
912976
.map_err(|e| ApiError::Internal(e.to_string()))?
913977
.ok_or_else(|| ApiError::NotFound(format!("Octad {} not found", id)))?;
914978

915-
Ok(Json(OctadResponse::from(&octad)))
979+
let flags = IncludeFlags::parse(query.include.as_deref());
980+
Ok(Json(OctadResponse::with_includes(&octad, flags)))
916981
}
917982

918983
/// Update octad handler
@@ -2316,6 +2381,107 @@ mod tests {
23162381
assert_eq!(response.status(), StatusCode::OK);
23172382
}
23182383

2384+
#[tokio::test]
2385+
async fn test_include_types_round_trip() {
2386+
// Phase 1 gap closure: GET /octads/{id}?include=types must surface the
2387+
// entity's declared semantic types as semantic_types: Vec<String>. The
2388+
// default GET (no include) must still omit the field so legacy clients
2389+
// see no schema change.
2390+
let state = create_test_state().await;
2391+
let app = build_router(state);
2392+
2393+
let create_request = OctadRequest {
2394+
title: Some("Typed entity".to_string()),
2395+
body: Some("Body".to_string()),
2396+
embedding: Some(vec![0.1, 0.2, 0.3]),
2397+
types: Some(vec![
2398+
"https://example.org/Email".to_string(),
2399+
"https://schema.org/Message".to_string(),
2400+
]),
2401+
relationships: None,
2402+
tensor: None,
2403+
temporal: None,
2404+
metadata: None,
2405+
provenance: None,
2406+
spatial: None,
2407+
};
2408+
let response = app
2409+
.clone()
2410+
.oneshot(
2411+
Request::builder()
2412+
.method("POST")
2413+
.uri("/octads")
2414+
.header("content-type", "application/json")
2415+
.body(Body::from(
2416+
serde_json::to_string(&create_request).expect("serialize create request"),
2417+
))
2418+
.expect("build POST request"),
2419+
)
2420+
.await
2421+
.expect("oneshot create");
2422+
assert_eq!(response.status(), StatusCode::CREATED);
2423+
2424+
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
2425+
.await
2426+
.expect("read create body");
2427+
let created: OctadResponse =
2428+
serde_json::from_slice(&body).expect("decode create response");
2429+
assert!(
2430+
created.semantic_types.is_none(),
2431+
"default response (POST) must not surface semantic_types"
2432+
);
2433+
2434+
// GET without include — types still omitted
2435+
let response = app
2436+
.clone()
2437+
.oneshot(
2438+
Request::builder()
2439+
.uri(format!("/octads/{}", created.id))
2440+
.body(Body::empty())
2441+
.expect("build GET request"),
2442+
)
2443+
.await
2444+
.expect("oneshot get");
2445+
assert_eq!(response.status(), StatusCode::OK);
2446+
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
2447+
.await
2448+
.expect("read get body");
2449+
let got: OctadResponse = serde_json::from_slice(&body).expect("decode get response");
2450+
assert!(
2451+
got.semantic_types.is_none(),
2452+
"default GET must not surface semantic_types"
2453+
);
2454+
2455+
// GET with ?include=types — types surfaced verbatim
2456+
let response = app
2457+
.oneshot(
2458+
Request::builder()
2459+
.uri(format!("/octads/{}?include=types", created.id))
2460+
.body(Body::empty())
2461+
.expect("build GET request"),
2462+
)
2463+
.await
2464+
.expect("oneshot get with include");
2465+
assert_eq!(response.status(), StatusCode::OK);
2466+
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
2467+
.await
2468+
.expect("read get body");
2469+
let got: OctadResponse =
2470+
serde_json::from_slice(&body).expect("decode get response with include");
2471+
let types = got
2472+
.semantic_types
2473+
.as_ref()
2474+
.expect("?include=types must surface semantic_types");
2475+
assert_eq!(
2476+
types,
2477+
&vec![
2478+
"https://example.org/Email".to_string(),
2479+
"https://schema.org/Message".to_string(),
2480+
],
2481+
"semantic_types must round-trip in declaration order"
2482+
);
2483+
}
2484+
23192485
#[tokio::test]
23202486
async fn test_temporal_observed_at_round_trip() {
23212487
// Phase 1 gap closure: caller-supplied real-world time (e.g. an email's

0 commit comments

Comments
 (0)