Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang

## [Unreleased]

- `tepp_api` loopback `AnalysisRunLiveService` now serves `GET /v1/analysis-runs/{run_id}` so accepted/running statuses stay metric-free and only a succeeded status with profile `scientific_acceptance_v1` may return `tepp.scientific_acceptance.v1`. Receipt RMSE/bias/coverage/SE-gate keys, a GET body, failed-plus-artifact emission, an all-zero digest, and digest mismatch fail closed. This is the GAP-003A HTTP status slice for issue #166; it does not duplicate the `analysis_engine` library bind (#356) or the terminal-result DTO wire (#358); persistence remains GAP-003B.

- `event_core` adds bounded Allen interval-consistency classification, atomic path-consistency closure, contradiction/resource refusals, and an explicit dependency-error fallback without claiming unrestricted global satisfiability.

- `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Table 2, p. 12 `MANIFESTTRAITVAR`; §7.1, p. 19; p. 16 `MANIFESTTRAITVARstd`; footnote 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-27T14:20Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar standardised manifest-trait variance on current main after `0ce16e8` dropped the pre-consolidation code while research notes already named the map (register items 83–84). Table 2 names `MANIFESTTRAITVAR` `Ψ_τ` the additional time-invariant variance-covariance on the measurement level and sets it `NULL` when there is no manifest trait. Equation 5 writes `Γ ~ N(τ, Ψ)` and names that covariance the manifest traits. Section 7.1 names manifest traits stable individual differences in indicator levels, distinct from process-level `TRAITVAR` `φ_ξ`. Page 16 prints standardised matrices with the suffix `std` when appropriate. The printed example on p. 16 is `discreteDRIFTstd`, not `MANIFESTTRAITVARstd`. Footnote 4 standardises using only the relevant variance, not the total. The relevant variance for that named indicator-level correlation is `MANIFESTTRAITVAR`, not process-level `TRAITVAR` and not residual `MANIFESTVAR` `θ`. The 2017-era source forms `MANIFESTTRAITVARstd` only when `MANIFESTTRAITVAR != 0`, as `solve(sqrt(diag(MANIFESTTRAITVAR) + ridging)) %&% MANIFESTTRAITVAR` when `verbose = TRUE`. OpenMx `%&%` is `t(A) %*% B %*% A`. Unlike `TRAITVARstd`, that formation adds `diag(c(ridging), n.manifest)`. The default `ridging = FALSE` adds 0, not `0.0001`; that ridge is a numerical hack and is not this exact map. The scalar correlation is `ψ / ψ = 1` after strictly positive `MANIFESTTRAITVAR`. Form strictly positive `ψ` first, then `1 / √ψ`, then `(1 / √ψ) ψ (1 / √ψ)`. Unstandardised `MANIFESTTRAITVAR` is defined for a zero trait; standardised `MANIFESTTRAITVAR` is not. Zero `MANIFESTTRAITVAR` skips forming `MANIFESTTRAITVARstd` in the 2017-era source and fails closed here. Indicator-level trait variance is an event-time structural quantity, so a non-event clock fails closed. `MANIFESTTRAITVAR` does not require stable `a < 0`. Distinct positive `ψ` recover the same 1. `trait / trait = 1` is `TRAITVARstd` and recovers the same number and remains a distinct named quantity. `θ` is `MANIFESTVAR` and is measurement error, not this correlation. Meredith (1993) remains unread (web search 2026-08-27T14:20Z: Springer/Cambridge Core paywalled; Unpaywall historically `is_oa: false`; Springer `content/pdf` is an HTML stub). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread on the same terms (DOI `10.1007/bf02294457`). Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation.
Expand Down
423 changes: 405 additions & 18 deletions crates/tepp_api/src/analysis_run_live.rs

Large diffs are not rendered by default.

128 changes: 128 additions & 0 deletions crates/tepp_api/src/analysis_run_status_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,74 @@ fn encode_path_segment(value: &str) -> String {
out
}

/// Decode one status-path segment and refuse empty, slash, or hostile values.
///
/// # Errors
///
/// Returns [`ApiError::InvalidWirePayload`] for truncated encodings, non-UTF-8
/// octets, empty results, or a decoded slash/NUL.
pub(crate) fn decode_path_segment(value: &str) -> Result<String, ApiError> {
let mut out = Vec::with_capacity(value.len());
let bytes = value.as_bytes();
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b'%' => {
if index + 2 >= bytes.len() {
return Err(ApiError::InvalidWirePayload);
}
let hi = from_hex(bytes[index + 1])?;
let lo = from_hex(bytes[index + 2])?;
out.push((hi << 4) | lo);
index += 3;
}
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(bytes[index]);
index += 1;
}
_ => return Err(ApiError::InvalidWirePayload),
}
}
let decoded = String::from_utf8(out).map_err(|_| ApiError::InvalidWirePayload)?;
if decoded.is_empty() || decoded.contains('/') || decoded.contains('\0') {
return Err(ApiError::InvalidWirePayload);
}
Ok(decoded)
}

fn from_hex(byte: u8) -> Result<u8, ApiError> {
match byte {
b'0'..=b'9' => Ok(byte - b'0'),
b'a'..=b'f' => Ok(byte - b'a' + 10),
b'A'..=b'F' => Ok(byte - b'A' + 10),
_ => Err(ApiError::InvalidWirePayload),
}
}

/// Extract the opaque run identity from `GET /v1/analysis-runs/{run_id}`.
///
/// # Errors
///
/// Returns [`ApiError::InvalidWirePayload`] for a collection path, extra
/// segments, or a hostile encoding, and [`ApiError::LimitExceeded`] when the
/// decoded identity exceeds [`ANALYSIS_RUN_ID_MAX_LEN`].
pub(crate) fn analysis_run_status_path_run_id(path: &str) -> Result<String, ApiError> {
let remainder = path
.strip_prefix(ANALYSIS_RUN_STATUS_PATH)
.ok_or(ApiError::InvalidWirePayload)?;
let encoded = remainder
.strip_prefix('/')
.ok_or(ApiError::InvalidWirePayload)?;
if encoded.is_empty() || encoded.contains('/') {
return Err(ApiError::InvalidWirePayload);
}
let run_id = decode_path_segment(encoded)?;
if run_id.len() > ANALYSIS_RUN_ID_MAX_LEN {
return Err(ApiError::LimitExceeded);
}
Ok(run_id)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -112,4 +180,64 @@ mod tests {
let result = naruon_analysis_run_status_exchange("https://t.example.com", &big, "k");
assert_eq!(result.unwrap_err(), ApiError::LimitExceeded);
}

#[test]
fn decodes_status_path_identities_and_refuses_hostile_segments() {
assert_eq!(
analysis_run_status_path_run_id("/v1/analysis-runs/tepp-run-1").expect("plain"),
"tepp-run-1"
);
assert_eq!(
decode_path_segment("run%2dabc").expect("lower hex"),
"run-abc"
);
assert_eq!(
decode_path_segment("run%2Dabc").expect("upper hex"),
"run-abc"
);
assert_eq!(
analysis_run_status_path_run_id("/v1/analysis-runs"),
Err(ApiError::InvalidWirePayload)
);
assert_eq!(
analysis_run_status_path_run_id("/v1/other/tepp-run-1"),
Err(ApiError::InvalidWirePayload)
);
assert_eq!(
analysis_run_status_path_run_id("/v1/analysis-runs/"),
Err(ApiError::InvalidWirePayload)
);
assert_eq!(
analysis_run_status_path_run_id("/v1/analysis-runs/a/b"),
Err(ApiError::InvalidWirePayload)
);
assert_eq!(
analysis_run_status_path_run_id("/v1/analysis-runs/%2F"),
Err(ApiError::InvalidWirePayload)
);
assert_eq!(decode_path_segment("%"), Err(ApiError::InvalidWirePayload));
assert_eq!(decode_path_segment("%2"), Err(ApiError::InvalidWirePayload));
assert_eq!(
decode_path_segment("%ZZ"),
Err(ApiError::InvalidWirePayload)
);
assert_eq!(
decode_path_segment("run id"),
Err(ApiError::InvalidWirePayload)
);
assert_eq!(
decode_path_segment("%00"),
Err(ApiError::InvalidWirePayload)
);
let oversized = "a".repeat(ANALYSIS_RUN_ID_MAX_LEN + 1);
assert_eq!(
analysis_run_status_path_run_id(&format!("/v1/analysis-runs/{oversized}")),
Err(ApiError::LimitExceeded)
);
let invalid_utf8 = "%FF";
assert_eq!(
decode_path_segment(invalid_utf8),
Err(ApiError::InvalidWirePayload)
);
}
}
14 changes: 13 additions & 1 deletion crates/tepp_api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
//! may also request a cutoff-safe project-history projection from explicit
//! source evidence. Naruon owns the current purpose-bound export adapter.
//! Loopback listeners prove the HTTP boundary without claiming production TLS,
//! causality, or completed psychometric model results.
//! causality, or completed psychometric model results. `GET /v1/analysis-runs/{run_id}`
//! on the loopback listener keeps accepted and running statuses metric-free;
//! only a succeeded status with profile `scientific_acceptance_v1` may return
//! `tepp.scientific_acceptance.v1`.

mod analysis_result;
mod analysis_run;
Expand All @@ -30,6 +33,7 @@ mod orchestration;
mod project_history;
mod project_journey;
mod provider_payload;
mod scientific_acceptance_http;
mod temporal_context;
mod wire;

Expand Down Expand Up @@ -95,6 +99,14 @@ pub use export::GraphMlExport;
pub use export::JsonLdExport;
/// Reproducibility manifest.
pub use export::ReproducibilityManifest;
/// Output profile that authorizes scientific-acceptance on a loopback GET.
pub use scientific_acceptance_http::SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE;
/// Schema identity returned on a succeeded scientific-acceptance GET.
pub use scientific_acceptance_http::SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA;
/// Detect scientific-metric keys on a receipt JSON object.
pub use scientific_acceptance_http::receipt_json_carries_scientific_metrics;
/// Refuse scientific-metric keys on request, accepted, or non-terminal status JSON.
pub use scientific_acceptance_http::refuse_metrics_on_receipt;

/// Analytical export purpose.
pub use authorization::AnalyticalPurpose;
Expand Down
Loading
Loading