diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a027f4e3e..ac511e679 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -70,7 +70,7 @@ boundaries above remain the target modular MSA architecture. | `tepp_simulation` | known-truth temporal/event data generation | | `validation_core` | RMSE, bias, coverage, graph, Monte Carlo, and exact-head claim-promotion metrics | | `tepp_api` | versioned DTO, schema, terminal-result, and export contracts | -| `analysis_engine` | bounded cutoff-safe temporal evidence readiness execution, digest-bound terminal artifacts, GAP-003A scientific-acceptance validation runs (`tepp.scientific_acceptance.v1`; not implemented-main), and loopback `POST /v1/analysis-runs/{run_id}/execute` that produces that artifact without a caller-supplied payload | +| `analysis_engine` | bounded cutoff-safe temporal evidence readiness execution, digest-bound terminal artifacts, GAP-003A scientific-acceptance validation runs (`tepp.scientific_acceptance.v1`; not implemented-main), loopback `POST /v1/analysis-runs/{run_id}/execute` that produces that artifact without a caller-supplied payload, and the published `tepp-loopback` binary that binds that wrapper | | `episode_membership` | event-time episode membership containment gate | | `prompt_source` | prompt boilerplate is not unique latent content and not stopword deletion | | `corpus_background` | corpus-background wording is not unique latent content and not stopword deletion | @@ -113,7 +113,7 @@ boundaries above remain the target modular MSA architecture. | `episode_membership` | episode membership cannot escape the episode event-time interval | | `membership_target` | language, episode, template, department, and opportunity-pool targets cannot collapse into entity or project | | `topic_measurement` | logistic-normal ALR/ILR coordinates and the CPU `f64` TRSL-TM reference estimator | -| `analysis_engine` | bounded cutoff-safe temporal evidence readiness execution, digest-bound terminal artifacts, GAP-003A scientific-acceptance validation runs (`tepp.scientific_acceptance.v1`; not implemented-main), and loopback `POST /v1/analysis-runs/{run_id}/execute` that produces that artifact without a caller-supplied payload | +| `analysis_engine` | bounded cutoff-safe temporal evidence readiness execution, digest-bound terminal artifacts, GAP-003A scientific-acceptance validation runs (`tepp.scientific_acceptance.v1`; not implemented-main), loopback `POST /v1/analysis-runs/{run_id}/execute` that produces that artifact without a caller-supplied payload, and the published `tepp-loopback` binary that binds that wrapper | | `psychometric_core` | posterior-aware structural input gates, CWC within/between OLS plus the contextual effect, event-time log-rate, unequal-interval discrete-lag remapping, constant-predictor discrete effect, time-varying-predictor discrete effect (Eq. 14), exact scalar discrete process noise (Driver et al., 2017, Eq. 3), lagged latent covariance and unconditional latent variance (Driver et al., 2017, Eq. 3–4), stationary within-subject variance (Driver et al., 2017, Eq. 4 as `Δt → ∞`; `asymDIFFUSION`), trait-plus-state variance (Driver et al., 2017, §4.3 `TRAITVAR`; not process noise), observed-indicator variance and lagged observed covariance (Driver et al., 2017, Eq. 5; Table 2 `MANIFESTVAR` is `Θ`, not `Var(y)`; `MANIFESTTRAITVAR` is not `MANIFESTVAR`; `Θ` does not enter lagged observed covariance; observed-indicator mean is `τ + λ μ`; `MANIFESTMEANS` is not `E(y)`; `CINT` is not `MANIFESTMEANS`; discrete latent mean is `exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ`; `T0MEANS` is not `μ_t`; `CINT` is not the discrete increment; evolved observed mean is `τ + λ μ_t`; `τ + λ μ_0` is not `E(y_t)`; contemporaneous `TDPREDEFFECT` impulse is `m x`, not `CINT`, not `TIPREDEFFECT`, and not Voelkle Eq. 14; Eq. 5 of that contemporaneous impulse is `τ + λ(μ_t + m x)`, and `τ + λ μ_t` is not that observed mean; time-independent `TIPREDEFFECT` increment is `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, not `M x`, not Voelkle Eq. 14, and not the coefficient `B`; Eq. 5 of that increment is `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that observed mean; `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t`; within-interval `TDPREDEFFECT` carry is `e^{A(t−u)} M x` for `t0 < u < t`, not the contemporaneous Dirac, not `CINT`, not `TIPREDEFFECT`, and not Voelkle Eq. 14; Eq. 5 of that carry is `τ + λ(μ_t + e^{a(t−u)} m x)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that carried observed mean when `u ≠ t`; first-occasion `T0TIPREDEFFECT` shift is `t0_b z` and Eq. 3 first-summand carry is `e^{A Δt} t0_b z` (`T0TIPREDEFFECT` is not `TIPREDEFFECT` `B`; `t0_b z` is not `A^{-1}[e^{A Δt} − I] B z`; `e^{A Δt} t0_b z` is not `t0_b z`; Eq. 5 of that carry is `τ + λ(μ_t + e^{a Δt} t0_b z)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not that observed mean), first-occasion `T0TDPREDEFFECT` shift is `t0_m x0` and Eq. 3 first-summand carry is `e^{A Δt} t0_m x0` (`T0TDPREDEFFECT` is not `TDPREDEFFECT` `M`; `t0_m x0` is not `M x`; `e^{A Δt} t0_m x0` is not `t0_m x0`; `e^{A Δt} t0_m x0` is not `e^{A(t−u)} M x` for `t0 < u < t`; `t0_m x0` is not `t0_b z`; an impulse at `u ≤ t0` that used `M` is already in `η(t0)` as `TDPREDEFFECT`, not as `T0TDPREDEFFECT`; Eq. 5 of that carry is `τ + λ(μ_t + e^{a Δt} t0_m x0)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not that observed mean; `τ + λ(μ_t + e^{a Δt} t0_b z)` is not that observed mean; §7.2 level-change `CINT` is `κ = −a m x` with `a < 0` so `−κ / a = m x` (`−a m x` is not the dissipating Dirac, not a free `CINT`, not `TIPREDEFFECT`, and not the extra near-zero-drift latent process also named in §7.2; Eq. 3 of that setting is `(1 − e^{a Δt}) m x`, which is not `m x`, not `κ`, and not `TIPREDEFFECT`; §7.2 extra-process contribution is `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)` (`ε = a` is `a_{ηξ} x Δt e^{a Δt}`; identification `TDPREDEFFECT` on the extra process is 1; printed extra `DRIFT` is `−0.000001`; not `κ = −a m x`, not `(1 − e^{a Δt}) m x`, and not the dissipating Dirac `m x`; `ε ≥ 0` fails closed; Eq. 5 of that contribution is `τ + λ(μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)`; the extra process has `LAMBDA` 0 and is not an observed indicator; `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that observed mean; the contribution is not `E(y_t)`; the evolved-plus-contribution latent mean is not `E(y_t)`; after-t0 extra-process `TDPREDEFFECT` is `a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a)` for `t0 < u < t` while `μ_t` uses `Δt`; Eq. 5 of that after-t0 contribution is `τ + λ(μ_t + a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a)`; the first-occasion extra-process observed mean is not that observed mean when `u ≠ t0`; `e^{a(t−u)} m x` is a Dirac on the original process, not this `DRIFT` drive; §7.2 `asymTIPREDEFFECT` is `-B z / a` for `a < 0` (`-B z / a` is not the coefficient `B`, not `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, and not `M x`; §7.2 `addedTIPREDVAR` is `(B / a)² v`, not `TRAITVAR`, not `asymDIFFUSION`, and not `-B z / a`; Table 2 `asymCINT` is `-κ / a` for `a < 0` and is not `κ`, not `A^{-1}[e^{A Δt} − I] κ`, not `T0MEANS`, and not `-B z / a`; p. 16 stationary `T0MEANS` is `-κ / a + −B z / a` and is not free `T0MEANS`, not `asymCINT` alone, not `asymTIPREDEFFECT` alone, and not the finite-interval discrete latent mean; Eq. 5 of that constrained mean is `τ + λ(−κ / a + −B z / a)`; `τ + λ μ_0` is not that observed mean; `τ + λ(−κ / a)` is not that observed mean when `B z ≠ 0`; `τ + λ μ_t` is not that observed mean; `MANIFESTMEANS` is not `E(y_0)`; the constrained latent mean is not `E(y_0)`; stationary `T0VAR` is `trait + −q / (2 a) + (B / a)² v` (not free `T0VAR`, not `asymDIFFUSION` alone, not `TRAITVAR` alone, not `addedTIPREDVAR` alone, and not the finite-interval discrete latent variance. Eq. 5 of that constrained variance is `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` (JSS PDF re-opened 2026-08-22T03:20Z; form the stationary latent variance first, then `λ² p + θ + ψ`; `λ² p_0` is not that observed variance; `λ²(−q / (2 a)) + θ` is not that observed variance when `TRAITVAR` or `addedTIPREDVAR` is nonzero; `MANIFESTVAR` is not `Var(y_0)`; the constrained latent variance is not `Var(y_0)`); lagged stationary `T0VAR` is `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v` (trait and `addedTIPREDVAR` do not decay; contemporaneous `T0VAR` is not that lagged map; decaying the constrained total as if it were all state is not that lagged map; Eq. 5 of that lagged covariance is `λ²(trait + e^{a Δt}(−q / (2 a)) + (B / a)² v) + ψ`; `Θ` does not enter; contemporaneous `Var(y_0)` is not that lagged observed covariance; the lagged latent covariance is not that observed covariance); later-occasion stationary `T0VAR` is `trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v` (trait and `addedTIPREDVAR` do not enter `Q_Δt`; under stationarity that composition equals contemporaneous `T0VAR`; evolving the constrained total as if it were all state is not that later map; the lagged covariance omits `Q_Δt`; `Q_Δt` is not that later map; Eq. 5 of that later-occasion variance is `λ²(trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v) + θ + ψ`; lagged observed covariance omits `Q_Δt` and `θ`; `MANIFESTVAR` is not `Var(y_t)`; the later-occasion latent variance is not `Var(y_t)`))), irregular already-centered residual lag, Rubin `T` on OLS loadings, and strong-gated latent means (two-observation residual variance is identically `0` and caps at strong/scalar; Putnick & Bornstein, 2016) | | `validation_core` | RMSE, bias, coverage, graph, and Monte Carlo metrics | | `tepp_api` | versioned DTO, schema, and export contracts | diff --git a/CHANGELOG.d/scientific-acceptance-loopback-binary.md b/CHANGELOG.d/scientific-acceptance-loopback-binary.md new file mode 100644 index 000000000..f3c5f1a24 --- /dev/null +++ b/CHANGELOG.d/scientific-acceptance-loopback-binary.md @@ -0,0 +1,3 @@ +### Added + +- `analysis_engine` GAP-003A published-binary slice (ADR 0033, active-PR, not implemented-main): the `tepp-loopback` binary now binds `ScientificAcceptanceLoopbackService` so `POST /v1/analysis-runs/{run_id}/execute` is reachable on the packaged loopback listener without embedding the library. Persistence remains GAP-003B. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dff8bc6e..819c64439 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ## [Unreleased] +- `analysis_engine` GAP-003A published-binary slice (ADR 0033, active-PR, not implemented-main): the `tepp-loopback` binary now binds `ScientificAcceptanceLoopbackService` so `POST /v1/analysis-runs/{run_id}/execute` is reachable on the packaged loopback listener without embedding the library. CLI arguments, default bind `127.0.0.1:18081`, and temporal-context health checks are unchanged. `tepp_api` no longer ships that binary (crate cycle). This does not duplicate the engine-execute library (#370), loopback CLI (#362), collection CLI (#371), GET (#359), lifecycle POST (#360), cancel HTTP (#361), collection GET (#368), retry HTTP (#369), DTO (#358), or engine library (#356); persistence remains GAP-003B. + - `analysis_engine` GAP-003A engine-on-loopback slice (ADR 0032, active-PR, not implemented-main): `ScientificAcceptanceLoopbackService` serves `POST /v1/analysis-runs/{run_id}/execute` so an accepted `scientific_acceptance_v1` loopback run produces `tepp.scientific_acceptance.v1` without a caller-supplied artifact. The execute body carries corpus, recovery, seed, and pre-registered SE-gate `k` and refuses `scientific_acceptance_json` plus receipt metric keys. Wrong profile, LLM recovery, unknown run, consumer mismatch, already-terminal status, and digest mismatch fail closed. This does not duplicate the engine library (#356), terminal-result DTO (#358), GET (#359), lifecycle POST (#360), cancel HTTP (#361), loopback CLI (#362), or collection GET (#368); persistence remains GAP-003B. - `analysis_engine` GAP-003A first slice (ADR 0026, active-PR, not implemented-main): `submit_validation_run` binds cutoff-eligible evidence identities, tenant workspace, snapshot, knowledge cutoff, CPU `f64` model, seed, backend, precision, output profile, and a pre-registered SE-gate multiplier `k` (`0 ≤ k ≤ MAX_SE_GATE_K` = 8) to a hash-stable `tepp-validation-{32 hex}` receipt that carries no scientific metrics. Receipt fields are private. `complete_validation_run` requires recovery vectors stamped to that receipt with the same `k`, records a SHA-256 of those vectors, and emits `tepp.scientific_acceptance.v1` with RMSE, bias, Wilson coverage, temporal-order accuracy, and an SE-aware gate through `validation_core` using the submitted `k`. LLM-authored recovery, a different run/tenant/seed/evidence set, a post-hoc or oversized `k`, a tampered output profile, oversized/empty/mismatched vectors, non-finite inputs, empty or duplicate evidence, snapshot mismatch, and cutoff-empty corpora fail closed. Postgres persistence remains GAP-003B. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index a39eb96b0..c39d5fd64 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -60,6 +60,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Scientific claim-promotion gate doctoring | [`docs/research/scientific-claim-promotion-gates.md`](docs/research/scientific-claim-promotion-gates.md) | | Validation-run scientific-acceptance doctoring | [`docs/research/validation-run-scientific-acceptance.md`](docs/research/validation-run-scientific-acceptance.md) | | Scientific-acceptance loopback engine doctoring | [`docs/research/scientific-acceptance-loopback-engine.md`](docs/research/scientific-acceptance-loopback-engine.md) | +| Scientific-acceptance published loopback binary doctoring | [`docs/research/scientific-acceptance-loopback-binary.md`](docs/research/scientific-acceptance-loopback-binary.md) | | Retention/deletion/legal-hold doctoring | [`docs/research/retention-deletion-legal-hold.md`](docs/research/retention-deletion-legal-hold.md) | | Provider-payload minimization doctoring | [`docs/research/provider-payload-minimization.md`](docs/research/provider-payload-minimization.md) | | Relation absence is not negative evidence | [`docs/research/relation-absence-not-negative.md`](docs/research/relation-absence-not-negative.md) | @@ -151,6 +152,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Scientific claim-promotion gate doctoring | [`docs/research/scientific-claim-promotion-gates.md`](docs/research/scientific-claim-promotion-gates.md) | | Validation-run scientific-acceptance doctoring | [`docs/research/validation-run-scientific-acceptance.md`](docs/research/validation-run-scientific-acceptance.md) | | Scientific-acceptance loopback engine doctoring | [`docs/research/scientific-acceptance-loopback-engine.md`](docs/research/scientific-acceptance-loopback-engine.md) | +| Scientific-acceptance published loopback binary doctoring | [`docs/research/scientific-acceptance-loopback-binary.md`](docs/research/scientific-acceptance-loopback-binary.md) | | Retention/deletion/legal-hold doctoring | [`docs/research/retention-deletion-legal-hold.md`](docs/research/retention-deletion-legal-hold.md) | | Stopword-deletion doctoring | [`docs/research/stopword-deletion.md`](docs/research/stopword-deletion.md) | | Provider-payload minimization doctoring | [`docs/research/provider-payload-minimization.md`](docs/research/provider-payload-minimization.md) | diff --git a/Dockerfile b/Dockerfile index 9031b9b45..682c0a18e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM rust:1.98.0-bookworm@sha256:e70e2eec3d495fd5c8e0be74adda86507dfac7f51a724fbf9813ff59b2b247c7 AS build WORKDIR /src COPY . . -RUN cargo build --locked --release -p tepp_api --bin tepp-loopback +RUN cargo build --locked --release -p analysis_engine --bin tepp-loopback FROM debian:bookworm-slim@sha256:abd67ffcfa541b485a3dff59865ab629aa048a6c613e639d36e7456b0b229241 RUN apt-get update \ diff --git a/crates/analysis_engine/Cargo.toml b/crates/analysis_engine/Cargo.toml index 0d384448b..460d3ef7f 100644 --- a/crates/analysis_engine/Cargo.toml +++ b/crates/analysis_engine/Cargo.toml @@ -29,5 +29,11 @@ corpus_split = { path = "../corpus_split", version = "0.2.0" } membership_core = { path = "../membership_core", version = "0.2.0" } relation_graph = { path = "../relation_graph", version = "0.2.0" } +[[bin]] +name = "tepp-loopback" +path = "src/bin/tepp_loopback.rs" +test = false +bench = false + [lints] workspace = true diff --git a/crates/tepp_api/src/bin/tepp_loopback.rs b/crates/analysis_engine/src/bin/tepp_loopback.rs similarity index 73% rename from crates/tepp_api/src/bin/tepp_loopback.rs rename to crates/analysis_engine/src/bin/tepp_loopback.rs index 90800a8cb..e81519333 100644 --- a/crates/tepp_api/src/bin/tepp_loopback.rs +++ b/crates/analysis_engine/src/bin/tepp_loopback.rs @@ -1,8 +1,8 @@ -//! Runnable loopback ingress for trusted same-host TEPP consumers. +//! Runnable loopback ingress that can execute scientific acceptance. use std::net::SocketAddr; -use tepp_api::AnalysisRunLiveService; +use analysis_engine::ScientificAcceptanceLoopbackService; const DEFAULT_BIND_ADDR: &str = "127.0.0.1:18081"; @@ -17,7 +17,7 @@ fn main() -> Result<(), Box> { .map(|value| value.parse::()) .transpose()? .unwrap_or(usize::MAX); - let mut service = AnalysisRunLiveService::bind(bind_addr)?; + let mut service = ScientificAcceptanceLoopbackService::bind(bind_addr)?; println!("{}", service.local_addr()?); (0..request_limit).for_each(|_| drop(service.serve_one())); Ok(()) diff --git a/crates/analysis_engine/src/lib.rs b/crates/analysis_engine/src/lib.rs index adebea503..55321ca0e 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -11,7 +11,9 @@ //! contracts and preserves their artifact meaning. Loopback //! [`ScientificAcceptanceLoopbackService`] executes scientific acceptance on an //! accepted analysis run so GET can return `tepp.scientific_acceptance.v1` -//! without a caller-supplied artifact. +//! without a caller-supplied artifact. The published `tepp-loopback` binary +//! binds that wrapper so `POST /v1/analysis-runs/{run_id}/execute` is reachable +//! on the loopback listener without embedding this crate. mod case_deletion_refit; mod lineage_criterion; diff --git a/crates/analysis_engine/src/loopback_execute.rs b/crates/analysis_engine/src/loopback_execute.rs index 14e8a655a..8e8101e5f 100644 --- a/crates/analysis_engine/src/loopback_execute.rs +++ b/crates/analysis_engine/src/loopback_execute.rs @@ -14,10 +14,11 @@ use crate::{ VALIDATION_CPU_F64_MODEL, complete_validation_run, submit_validation_run, }; use serde::Deserialize; +use std::net::SocketAddr; use temporal_core::{AvailableTime, EventTime}; use tepp_api::{ - ANALYSIS_RUN_STATUS_PATH, AnalysisResultSummary, AnalysisRunStatus, AnalysisRunStatusState, - AnalysisRunTerminalResult, ApiError, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, + ANALYSIS_RUN_STATUS_PATH, AnalysisResultSummary, AnalysisRunLiveService, AnalysisRunStatus, + AnalysisRunStatusState, AnalysisRunTerminalResult, ApiError, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, ErrorEnvelope, NaruonLiveResponse, SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE, SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA, analysis_run_execute_path_run_id, parse_loopback_http_parts, @@ -50,7 +51,7 @@ pub const ANALYSIS_RUN_EXECUTE_PATH_SUFFIX: &str = "execute"; /// engine produces the artifact. `tepp_api` cannot depend on this crate. #[derive(Debug)] pub struct ScientificAcceptanceLoopbackService { - live: tepp_api::AnalysisRunLiveService, + live: AnalysisRunLiveService, next_request_serial: u64, } @@ -65,28 +66,73 @@ impl ScientificAcceptanceLoopbackService { #[must_use] pub fn new() -> Self { Self { - live: tepp_api::AnalysisRunLiveService::new(), + live: AnalysisRunLiveService::new(), next_request_serial: 1, } } /// Wrap an existing loopback listener. #[must_use] - pub fn from_live(live: tepp_api::AnalysisRunLiveService) -> Self { + pub fn from_live(live: AnalysisRunLiveService) -> Self { Self { live, next_request_serial: 1, } } + /// Bind a caller-supplied loopback address. + /// + /// # Errors + /// + /// Returns [`ApiError::AuthorizationDenied`] for a non-loopback address + /// and [`ApiError::InvalidWirePayload`] when the socket cannot be opened. + pub fn bind(addr: SocketAddr) -> Result { + Ok(Self::from_live(AnalysisRunLiveService::bind(addr)?)) + } + + /// Bind an ephemeral IPv4 loopback port. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] when the operating system + /// refuses the loopback bind. + pub fn bind_loopback() -> Result { + Ok(Self::from_live(AnalysisRunLiveService::bind_loopback()?)) + } + + /// Return the bound loopback address. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] when no socket is bound. + pub fn local_addr(&self) -> Result { + self.live.local_addr() + } + + /// Accept and serve one HTTP/1.1 request, including `/execute`. + /// + /// # Errors + /// + /// Returns a fail-closed API error when no socket is bound or socket I/O + /// fails. Protocol errors are returned as redacted HTTP responses. + pub fn serve_one(&mut self) -> Result { + let (mut stream, request) = self.live.accept_loopback_request()?; + let response = match request { + Ok(request) => self.handle_http_request(&request), + Err(error) => self.response_from_error(error), + }; + AnalysisRunLiveService::write_loopback_response(&mut stream, &response)?; + Ok(response) + } + /// Return the inner loopback service. #[must_use] - pub fn live(&self) -> &tepp_api::AnalysisRunLiveService { + pub fn live(&self) -> &AnalysisRunLiveService { &self.live } /// Return the inner loopback service mutably. - pub fn live_mut(&mut self) -> &mut tepp_api::AnalysisRunLiveService { + pub fn live_mut(&mut self) -> &mut AnalysisRunLiveService { &mut self.live } diff --git a/crates/analysis_engine/tests/loopback_binary_contract.rs b/crates/analysis_engine/tests/loopback_binary_contract.rs new file mode 100644 index 000000000..12c119413 --- /dev/null +++ b/crates/analysis_engine/tests/loopback_binary_contract.rs @@ -0,0 +1,192 @@ +//! The packaged loopback binary serves temporal-context and execute. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +use analysis_engine::{ + ANALYSIS_RUN_EXECUTE_CONTRACT_VERSION, SCIENTIFIC_ACCEPTANCE_OUTPUT_PROFILE, + SCIENTIFIC_ACCEPTANCE_SCHEMA_VERSION, VALIDATION_CPU_F64_MODEL, +}; +use tepp_api::{ + ANALYSIS_RUN_CONTRACT_VERSION, NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, + SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE, SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA, +}; + +fn spawn_loopback(request_limit: &str) -> (Child, String) { + let mut child = Command::new(env!("CARGO_BIN_EXE_tepp-loopback")) + .args(["127.0.0.1:0", request_limit]) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn loopback service"); + let mut address = String::new(); + BufReader::new(child.stdout.take().expect("stdout")) + .read_line(&mut address) + .expect("bound address"); + (child, address) +} + +fn exchange(address: &str, request: &str) -> String { + let mut stream = TcpStream::connect(address.trim()).expect("connect"); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("read timeout"); + stream + .set_write_timeout(Some(Duration::from_secs(5))) + .expect("write timeout"); + stream.write_all(request.as_bytes()).expect("request"); + let mut response = String::new(); + stream.read_to_string(&mut response).expect("response"); + response +} + +fn http_post( + address: &str, + path: &str, + body: &str, + consumer: &str, + idempotency_key: &str, +) -> String { + format!( + "POST {path} HTTP/1.1\r\nHost: {}\r\ncontent-type: application/json\r\ntepp-consumer: {consumer}\r\ntepp-contract-version: 1\r\nidempotency-key: {idempotency_key}\r\ncontent-length: {}\r\n\r\n{body}", + address.trim(), + body.len() + ) +} + +fn http_get(address: &str, path: &str, consumer: &str, idempotency_key: &str) -> String { + format!( + "GET {path} HTTP/1.1\r\nHost: {}\r\ncontent-type: application/json\r\ntepp-consumer: {consumer}\r\ntepp-contract-version: 1\r\nidempotency-key: {idempotency_key}\r\ncontent-length: 0\r\n\r\n", + address.trim() + ) +} + +fn execute_body(run_id: &str) -> String { + serde_json::json!({ + "contract_version": ANALYSIS_RUN_EXECUTE_CONTRACT_VERSION, + "run_id": run_id, + "idempotency_key": "idem-loopback-binary", + "seed": 42, + "se_gate_k": 3.0, + "completed_at": "2026-08-31T11:00:00Z", + "study_label": "loopback-binary-recovery", + "authored_by_llm": false, + "corpus": { + "snapshot_id": "snapshot-binary", + "evidence_units": [ + { + "evidence_id": "evidence-1", + "event_time": "2026-07-01T00:00:00Z", + "available_time": "2026-07-10T00:00:00Z", + "membership_count": 1 + }, + { + "evidence_id": "evidence-2", + "event_time": "2026-07-01T00:00:00Z", + "available_time": "2026-07-20T00:00:00Z", + "membership_count": 1 + }, + { + "evidence_id": "future", + "event_time": "2026-07-01T00:00:00Z", + "available_time": "2026-08-02T00:00:00Z", + "membership_count": 1 + } + ] + }, + "truth": [0.70, 0.55, 0.40, -0.20, 0.85], + "recovered": [0.70, 0.55, 0.40, -0.20, 0.85], + "interval_lower": [0.50, 0.35, 0.20, -0.40, 0.65], + "interval_upper": [0.90, 0.75, 0.60, 0.00, 1.00], + "truth_times": [1.0, 2.0, 3.0, 4.0, 5.0], + "recovered_times": [1.1, 1.9, 3.2, 3.8, 5.1] + }) + .to_string() +} + +fn response_body(response: &str) -> &str { + response.split("\r\n\r\n").nth(1).expect("http body") +} + +#[test] +fn binary_serves_one_bounded_temporal_context_request() { + let (mut child, address) = spawn_loopback("1"); + let body = r#"{"contract_version":1,"consumer_code":"lineageweave","knowledge_cutoff":"2026-08-20T00:00:00Z","subject_post_id":"post-1","events":[{"event_id":"event-1","source_post_id":"post-1","event_type_code":"health_probe","event_label":"Health probe","event_time":"2026-08-20T00:00:00Z","available_time":"2026-08-20T00:00:00Z","project_reference":null,"actor_references":["actor-1"]}]}"#; + let request = format!( + "POST /v1/temporal-context HTTP/1.1\r\nHost: {}\r\ncontent-type: application/json\r\ntepp-consumer: lineageweave\r\ntepp-contract-version: 1\r\ncontent-length: {}\r\n\r\n{body}", + address.trim(), + body.len() + ); + let response = exchange(&address, &request); + assert!(response.starts_with("HTTP/1.1 200 OK"), "{response}"); + assert!(response.contains("association_not_causal")); + assert!(child.wait().expect("wait").success()); +} + +#[test] +fn binary_executes_scientific_acceptance_without_caller_artifact() { + let (mut child, address) = spawn_loopback("3"); + let create = serde_json::json!({ + "contract_version": ANALYSIS_RUN_CONTRACT_VERSION, + "idempotency_key": "idem-loopback-binary", + "tenant_workspace_id": "tenant-workspace-binary", + "snapshot_id": "snapshot-binary", + "knowledge_cutoff": "2026-08-01T00:00:00Z", + "model_contract_version": VALIDATION_CPU_F64_MODEL, + "output_profile": SCIENTIFIC_ACCEPTANCE_OUTPUT_PROFILE, + }) + .to_string(); + let accepted = exchange( + &address, + &http_post( + &address, + NARUON_ANALYSIS_RUN_PATH, + &create, + NARUON_CONSUMER_CODE, + "idem-loopback-binary", + ), + ); + assert!(accepted.starts_with("HTTP/1.1 202 Accepted"), "{accepted}"); + assert!(!accepted.contains("rmse")); + assert!(!accepted.contains("scientific_acceptance")); + let run_id = serde_json::from_str::(response_body(&accepted)) + .expect("accepted json")["run_id"] + .as_str() + .expect("run_id") + .to_owned(); + + let body = execute_body(&run_id); + assert!(!body.contains("scientific_acceptance_json")); + assert!(!body.contains("rmse")); + let execute = exchange( + &address, + &http_post( + &address, + &format!("{NARUON_ANALYSIS_RUN_PATH}/{run_id}/execute"), + &body, + NARUON_CONSUMER_CODE, + "idem-loopback-binary", + ), + ); + assert!(execute.starts_with("HTTP/1.1 200 OK"), "{execute}"); + assert!(execute.contains(SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA)); + assert!(execute.contains("scientific_acceptance")); + assert!(execute.contains("\"succeeded\"")); + + let get = exchange( + &address, + &http_get( + &address, + &format!("{NARUON_ANALYSIS_RUN_PATH}/{run_id}"), + NARUON_CONSUMER_CODE, + "idem-loopback-binary", + ), + ); + assert!(get.starts_with("HTTP/1.1 200 OK"), "{get}"); + assert!(get.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA_VERSION)); + assert!(get.contains(SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE)); + assert!(get.contains("scientific_acceptance")); + assert!(get.contains("rmse")); + assert!(child.wait().expect("wait").success()); +} diff --git a/crates/tepp_api/Cargo.toml b/crates/tepp_api/Cargo.toml index 053199f39..6f441b58c 100644 --- a/crates/tepp_api/Cargo.toml +++ b/crates/tepp_api/Cargo.toml @@ -23,12 +23,6 @@ sha2 = { workspace = true } temporal_core = { path = "../temporal_core", version = "0.2.0" } uuid = { workspace = true } -[[bin]] -name = "tepp-loopback" -path = "src/bin/tepp_loopback.rs" -test = false -bench = false - [lints] workspace = true diff --git a/crates/tepp_api/src/analysis_run_live.rs b/crates/tepp_api/src/analysis_run_live.rs index 1112ac7ce..d405c5ddc 100644 --- a/crates/tepp_api/src/analysis_run_live.rs +++ b/crates/tepp_api/src/analysis_run_live.rs @@ -143,13 +143,20 @@ impl AnalysisRunLiveService { self.bound_addr.ok_or(ApiError::InvalidWirePayload) } - /// Accept and serve one HTTP/1.1 request. + /// Accept one loopback connection and read its HTTP request. + /// + /// Socket I/O failures return [`Err`]. Protocol framing failures return + /// `Ok((stream, Err(_)))` so the caller can still write a redacted envelope + /// on the accepted stream. Engine glue uses this so execute dispatch can + /// own the handler without borrowing the listener twice. /// /// # Errors /// - /// Returns a fail-closed API error when no socket is bound or socket I/O - /// fails. Protocol errors are returned as redacted HTTP responses. - pub fn serve_one(&mut self) -> Result { + /// Returns [`ApiError::InvalidWirePayload`] when no socket is bound or the + /// accept/timeout syscalls fail. + pub fn accept_loopback_request( + &mut self, + ) -> Result<(std::net::TcpStream, Result), ApiError> { let listener = self.listener.as_ref().ok_or(ApiError::InvalidWirePayload)?; let (mut stream, _) = listener.accept().map_err(|error| map_io_error(&error))?; stream @@ -158,15 +165,39 @@ impl AnalysisRunLiveService { stream .set_write_timeout(Some(NARUON_LIVE_IO_TIMEOUT)) .map_err(|error| map_io_error(&error))?; - let response = match read_http_request_with_limit(&mut stream, MAX_LIVE_REQUEST_BODY_BYTES) - { - Ok(request) => self.handle_http_request(&request), - Err(error) => self.response_from_error(error), - }; + let request = read_http_request_with_limit(&mut stream, MAX_LIVE_REQUEST_BODY_BYTES); + Ok((stream, request)) + } + + /// Write one HTTP/1.1 response on an accepted loopback stream. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] when the write or flush fails. + pub fn write_loopback_response( + stream: &mut std::net::TcpStream, + response: &NaruonLiveResponse, + ) -> Result<(), ApiError> { stream .write_all(&response.to_http_bytes()) .map_err(|error| map_io_error(&error))?; stream.flush().map_err(|error| map_io_error(&error))?; + Ok(()) + } + + /// Accept and serve one HTTP/1.1 request. + /// + /// # Errors + /// + /// Returns a fail-closed API error when no socket is bound or socket I/O + /// fails. Protocol errors are returned as redacted HTTP responses. + pub fn serve_one(&mut self) -> Result { + let (mut stream, request) = self.accept_loopback_request()?; + let response = match request { + Ok(request) => self.handle_http_request(&request), + Err(error) => self.response_from_error(error), + }; + Self::write_loopback_response(&mut stream, &response)?; Ok(response) } diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index 19774f10c..f9d32464d 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -17,6 +17,7 @@ //! same loopback listener. `POST /v1/analysis-runs/{run_id}/execute` is refused //! here; `analysis_engine` owns engine execution so a scientific-acceptance run //! can produce `tepp.scientific_acceptance.v1` without a caller-supplied artifact. +//! The published `tepp-loopback` binary lives in `analysis_engine`. mod analysis_result; mod analysis_run; diff --git a/crates/tepp_api/tests/loopback_binary_contract.rs b/crates/tepp_api/tests/loopback_binary_contract.rs deleted file mode 100644 index 20e475647..000000000 --- a/crates/tepp_api/tests/loopback_binary_contract.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! The packaged loopback binary serves the published temporal-context wire. - -use std::io::{BufRead, BufReader, Read, Write}; -use std::net::TcpStream; -use std::process::{Command, Stdio}; - -#[test] -fn binary_serves_one_bounded_temporal_context_request() { - let mut child = Command::new(env!("CARGO_BIN_EXE_tepp-loopback")) - .args(["127.0.0.1:0", "1"]) - .stdout(Stdio::piped()) - .spawn() - .expect("spawn loopback service"); - let mut address = String::new(); - BufReader::new(child.stdout.take().expect("stdout")) - .read_line(&mut address) - .expect("bound address"); - let body = r#"{"contract_version":1,"consumer_code":"lineageweave","knowledge_cutoff":"2026-08-20T00:00:00Z","subject_post_id":"post-1","events":[{"event_id":"event-1","source_post_id":"post-1","event_type_code":"health_probe","event_label":"Health probe","event_time":"2026-08-20T00:00:00Z","available_time":"2026-08-20T00:00:00Z","project_reference":null,"actor_references":["actor-1"]}]}"#; - let request = format!( - "POST /v1/temporal-context HTTP/1.1\r\nHost: {}\r\ncontent-type: application/json\r\ntepp-consumer: lineageweave\r\ntepp-contract-version: 1\r\ncontent-length: {}\r\n\r\n{body}", - address.trim(), - body.len() - ); - let mut stream = TcpStream::connect(address.trim()).expect("connect"); - stream.write_all(request.as_bytes()).expect("request"); - let mut response = String::new(); - stream.read_to_string(&mut response).expect("response"); - assert!(response.starts_with("HTTP/1.1 200 OK")); - assert!(response.contains("association_not_causal")); - assert!(child.wait().expect("wait").success()); -} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 81762d265..1761c1e5f 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -8,7 +8,7 @@ TEPP must work both as a standalone product and as a modular CWL component. Integrations with `naruon`, `contextual-orchestrator`, `.github`, or other repositories use explicit versioned API/artifact contracts. Cross-service direct table access is prohibited. -Current protected main exposes Rust library/domain contracts. The active stack adds a loopback HTTP/1.1 listener for naruon analysis-run, LineageWeave temporal-context, and export POSTs, including `POST /v1/project-histories` on the `AnalysisRunLiveService` contract boundary. `tepp-loopback` runs the shared consumer listener on `127.0.0.1:18081` by default; a caller may pass another loopback socket address and an optional maximum request count as its two arguments. The container is intended for a trusted same-host or shared-network-namespace sidecar, checks readiness through a synthetic bounded temporal-context request, and deliberately cannot bind a public or bridge address. It is not a production TLS/`$PORT` service. Endpoint examples below that are not covered by `NaruonLiveService` or `AnalysisRunLiveService` remain target interface shapes; export retrieval stays a target shape until an executable export route ships. +Current protected main exposes Rust library/domain contracts. The active stack adds a loopback HTTP/1.1 listener for naruon analysis-run, LineageWeave temporal-context, and export POSTs, including `POST /v1/project-histories` on the `AnalysisRunLiveService` contract boundary. `tepp-loopback` is the `analysis_engine` binary that binds `ScientificAcceptanceLoopbackService` on `127.0.0.1:18081` by default; a caller may pass another loopback socket address and an optional maximum request count as its two arguments. The packaged listener serves create, GET, running, terminal, temporal-context, project-history, and `POST /v1/analysis-runs/{run_id}/execute` so a `scientific_acceptance_v1` run produces `tepp.scientific_acceptance.v1` without embedding the library. The container is intended for a trusted same-host or shared-network-namespace sidecar, checks readiness through a synthetic bounded temporal-context request, and deliberately cannot bind a public or bridge address. It is not a production TLS/`$PORT` service. Endpoint examples below that are not covered by `NaruonLiveService`, `AnalysisRunLiveService`, or `ScientificAcceptanceLoopbackService` remain target interface shapes; export retrieval stays a target shape until an executable export route ships. ## 2. Contract families @@ -68,6 +68,7 @@ POST /v1/temporal-context GET /v1/analysis-runs/{run_id} POST /v1/analysis-runs/{run_id}/running POST /v1/analysis-runs/{run_id}/terminal +POST /v1/analysis-runs/{run_id}/execute POST /v1/analysis-runs/{run_id}/cancel GET /v1/model-artifacts/{artifact_id} GET /v1/exports/{export_id} @@ -110,7 +111,9 @@ execute psychometric estimation. `POST /v1/analysis-runs/{run_id}/execute` on loopback path that produces `tepp.scientific_acceptance.v1` for a `scientific_acceptance_v1` run without a caller-supplied artifact. The execute body carries corpus, recovery, seed, and the pre-registered SE-gate multiplier -and refuses `scientific_acceptance_json`. Production TLS remains a later adapter. +and refuses `scientific_acceptance_json`. The published `tepp-loopback` binary +binds that wrapper so operators reach `/execute` without embedding +`analysis_engine`. Production TLS remains a later adapter. The stacked `analysis_engine` slice provides the first executable service-side path behind these DTOs. It consumes a bounded identity-free snapshot, excludes diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index f92f42e2d..deb35fa61 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -57,6 +57,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | loopback analysis-run scientific-acceptance GET | ADR 0027; API contract; RFC 9110; FIPS 180-4 | `tepp_api` `GET /v1/analysis-runs/{run_id}` on `AnalysisRunLiveService` (#359): accepted/running stay metric-free; `tepp.scientific_acceptance.v1` only on succeeded `scientific_acceptance_v1`; not implemented-main | active-PR | | loopback analysis-run scientific-acceptance lifecycle POST | ADR 0028; API contract; RFC 9110; FIPS 180-4 | `tepp_api` `POST /v1/analysis-runs/{run_id}/running` and `/terminal` on `AnalysisRunLiveService` (this PR): production status-update path; accepted/running stay metric-free; `tepp.scientific_acceptance.v1` only after succeeded `scientific_acceptance_v1`; not implemented-main | active-PR | | loopback scientific-acceptance engine execute | ADR 0032; ADR 0026/0027/0028; API contract; National Academies (2019); Wasserstein & Lazar (2016); Wilson (1927) | `analysis_engine` `POST /v1/analysis-runs/{run_id}/execute` on `ScientificAcceptanceLoopbackService` (this PR): engine produces `tepp.scientific_acceptance.v1` without caller-supplied artifact; metric keys/LLM/wrong-profile/unknown-run/consumer-mismatch/already-terminal fail closed; not implemented-main; Postgres persistence remains GAP-003B | active-PR | +| published scientific-acceptance loopback binary | ADR 0033; ADR 0032; API contract; National Academies (2019); Wasserstein & Lazar (2016); Wilson (1927) | `analysis_engine` `tepp-loopback` binary (this PR): packaged listener binds `ScientificAcceptanceLoopbackService` so POST create/execute then GET returns `tepp.scientific_acceptance.v1` without embedding the library; temporal-context health remains; `tepp_api` no longer ships the binary; not implemented-main; Postgres persistence remains GAP-003B | active-PR | | executable cutoff-safe analysis-run readiness | ADR 0021; temporal research; API terminal-result contract | stacked `analysis_engine` PR on #157: availability cutoff, snapshot binding, multiple-membership aggregation, digest-bound artifact, realistic end-to-end tests | active-PR | | delayed-reporting cutoff eligibility in truth corpora | ADR 0002; research | `tepp_simulation` eligible-at-cutoff filter on the active PR | active-PR | | versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); HTTP service remaining accepted-target | partial | diff --git a/docs/adr/0033-scientific-acceptance-loopback-binary.md b/docs/adr/0033-scientific-acceptance-loopback-binary.md new file mode 100644 index 000000000..db74a2150 --- /dev/null +++ b/docs/adr/0033-scientific-acceptance-loopback-binary.md @@ -0,0 +1,108 @@ +# ADR 0033 — Scientific-acceptance published loopback binary + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0032 (engine-on-loopback execute). Does not reuse ADR 0030, ADR 0031, or ADR 0032. Does not supersede ADR 0014 claim-promotion authority. + +## Context + +ADR 0032 wraps `AnalysisRunLiveService` so `POST /v1/analysis-runs/{run_id}/execute` +produces `tepp.scientific_acceptance.v1` without a caller-supplied artifact. +The published `tepp-loopback` binary still lived in `tepp_api` and bound the +raw listener, which refuses `/execute`. Operators therefore could not reach +engine execute on the packaged listener without embedding `analysis_engine`. +`tepp_api` cannot depend on `analysis_engine` (crate cycle). Duplicating the +engine-execute library (#370), loopback CLI (#362), collection CLI (#371), +GET, lifecycle POST, cancel, collection GET, retry, DTO, or engine-library +slices would collide with live PRs. Same-name binaries in two crates would +make `cargo --bin tepp-loopback` ambiguous. + +## Decision + +Move the published `tepp-loopback` binary into `analysis_engine`: + +- The binary binds [`ScientificAcceptanceLoopbackService`](../../crates/analysis_engine/src/loopback_execute.rs) + on the same CLI (`127.0.0.1:18081` by default; optional loopback address and + request limit). +- The Dockerfile builds `-p analysis_engine --bin tepp-loopback`. +- `tepp_api` no longer ships that binary. Create, GET, running, terminal, + temporal-context, and project-history stay delegated to the live service. +- `/execute` is reachable on the published listener without embedding the + library. Persistence remains GAP-003B. + +## Non-goals + +- Production TLS, public bind, or durable status storage. +- Leiden community detection, Driver p.16 std-family restoration, or Figma/export work. +- Promoting an ADR 0014 scientific claim from HTTP success. +- Collection GET, cancel HTTP, loopback CLI, collection CLI, retry HTTP, or + another engine-execute library wrapper. + +## Alternatives considered + +1. **Keep `tepp-loopback` in `tepp_api` and document library embedding** — + rejected because GAP-003A is operator-visible and the packaged listener + would still refuse `/execute`. +2. **Add `analysis_engine` as a `tepp_api` dependency** — rejected as a crate + cycle. +3. **Ship a second binary name** — rejected because operators and the + Dockerfile already call `tepp-loopback`. +4. **Move the same binary name onto the ADR 0032 wrapper** — accepted. + +## Consequences + +- Operators can run the published binary, POST create, POST execute, and GET + `tepp.scientific_acceptance.v1` without supplying the artifact and without + embedding `analysis_engine`. +- Temporal-context health checks remain valid. +- HTTP 200 on execute is not release evidence. + +## Failure and recovery + +Unknown run identities, extra path segments, metric keys, LLM recovery, wrong +profile, already-terminal runs, and consumer mismatch return a redacted `400` +envelope. Unsupported execute contract versions return `422`. Credential +headers remain `403`. Non-loopback bind remains fail-closed. The in-memory +registry is not durable. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- The published listener remains loopback-only, size-bounded, and content-redacting. +- SHA-256 digest agreement is a byte-identity check, not a validity claim. +- LLM-authored recovery cannot become scientific authority. + +## Compatibility and migration + +CLI arguments, default bind `127.0.0.1:18081`, and the container entrypoint +name are unchanged. Callers that embedded `AnalysisRunLiveService` still see +`/execute` refused; they must use the wrapper or the published binary. +Production adapters may replace loopback while preserving metric-free receipts +and engine-produced scientific acceptance. + +## Verification + +Falsifiable evidence: + +- the packaged binary still returns `200` for bounded temporal-context; +- POST create then POST execute without `scientific_acceptance_json` then GET + through the spawned binary returns `tepp.scientific_acceptance.v1`; +- Clippy `-D warnings`, `analysis_engine` and `tepp_api` tests, rustdoc, and + exact-head review remain required. + +## Rollback and supersession + +Rollback restores the `tepp_api` binary; ADR 0032 library execute remains +valid. A superseding ADR is required to persist status, bind a public address, +or treat HTTP success as an ADR 0014 claim. + +## Related authority + +- ADR 0032 owns engine-on-loopback execute. +- ADR 0026 owns the validation-run library bind. +- ADR 0027 owns the GET status read. +- ADR 0028 owns POST running/terminal. +- ADR 0018 owns consumer-scoped ingress and metric-free `202 Accepted`. +- ADR 0014 owns scientific claim promotion. +- ADR 0011 owns standalone/modular HTTP boundaries. diff --git a/docs/adr/README.md b/docs/adr/README.md index 4bff0761f..b786a4afd 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -34,6 +34,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0027](0027-scientific-acceptance-http-status.md) | Scientific-acceptance loopback HTTP status path | Accepted | active-PR | GET `/v1/analysis-runs/{run_id}` stays metric-free on accepted/running; `tepp.scientific_acceptance.v1` only on succeeded `scientific_acceptance_v1`. | | [0028](0028-scientific-acceptance-http-lifecycle.md) | Scientific-acceptance loopback HTTP lifecycle POST | Accepted | active-PR | POST `/running` and `/terminal` are the production status-update path; GET remains ADR 0027. Persistence remains GAP-003B. | | [0032](0032-scientific-acceptance-loopback-engine.md) | Scientific-acceptance loopback engine execute | Accepted | active-PR | POST `/execute` runs `analysis_engine` on the loopback lifecycle so GET returns `tepp.scientific_acceptance.v1` without a caller-supplied artifact. Persistence remains GAP-003B. | +| [0033](0033-scientific-acceptance-loopback-binary.md) | Scientific-acceptance published loopback binary | Accepted | active-PR | `tepp-loopback` moves to `analysis_engine` and binds `ScientificAcceptanceLoopbackService` so `/execute` is reachable on the packaged listener. Persistence remains GAP-003B. | | [0023](0023-lineage-criterion-anchor-contract.md) | TEPP-owned Event Lineage criterion anchor | Accepted | active-PR | PR #237 publishes the strict accepted/rejected artifact and identities; estimator execution remains fail-closed future work. | | [0024](0024-independent-topic-importance-anchor.md) | Posterior topic-context producer contract | Accepted | contract-only active-PR | Strict DTO/schema only; the current estimator does not emit it. fast-mlsirm owns case-deletion influence. | | [0001](0001-rust-first-modular-msa.md) | Rust-first numerical core and CPU `f64` reference | Accepted | partial | ADR 0011 owns cross-service/MSA authority; 0001 retains numerical/backend authority. | @@ -143,6 +144,7 @@ Use the narrowest owning ADR when decisions overlap: - **LineageWeave project-history service boundary:** ADR 0021. - **accepted-run execution and terminal artifact production:** ADR 0022. - **scientific-acceptance loopback engine execute:** ADR 0032. +- **scientific-acceptance published loopback binary:** ADR 0033. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index b28eec968..632084828 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -29,6 +29,7 @@ TEPP remains the scientific authority for estimation, recovery metrics, temporal | HTTP export authorize | `tepp_api` `naruon_export_exchange` → `POST /v1/exports` | naruon → TEPP | | Live loopback POST | `tepp_api` `NaruonLiveService` → `POST /v1/analysis-runs` and `/v1/exports` | naruon → TEPP | | Live loopback scientific-acceptance execute | `analysis_engine` `ScientificAcceptanceLoopbackService` → `POST /v1/analysis-runs/{run_id}/execute` | naruon → TEPP | +| Published `tepp-loopback` binary | `analysis_engine` `tepp-loopback` binds `ScientificAcceptanceLoopbackService` so `/execute` is reachable without embedding the library | naruon → TEPP | Committed examples live under `examples/`. Schemas for analysis-run requests and corpus-split manifests live under `schemas/`. diff --git a/docs/research/scientific-acceptance-loopback-binary.md b/docs/research/scientific-acceptance-loopback-binary.md new file mode 100644 index 000000000..2b191c70b --- /dev/null +++ b/docs/research/scientific-acceptance-loopback-binary.md @@ -0,0 +1,30 @@ +# Scientific-acceptance published loopback binary (GAP-003A) + +## Scope + +This note doctors the GAP-003A published-binary slice: + +1. the packaged `tepp-loopback` binary binds `ScientificAcceptanceLoopbackService` so `POST /v1/analysis-runs/{run_id}/execute` is reachable without embedding `analysis_engine`; +2. the binary still serves bounded `POST /v1/temporal-context`; +3. POST create then POST execute without `scientific_acceptance_json` then GET through the spawned binary returns `tepp.scientific_acceptance.v1`; +4. `tepp_api` no longer ships `tepp-loopback` (crate cycle; `cargo --bin` ambiguity). + +Postgres persistence, restart/recovery, and Compose execution remain GAP-003B. This slice is not implemented-main. It does not duplicate the engine-execute library (#370), loopback CLI (#362), collection CLI (#371), GET (#359), lifecycle POST (#360), cancel HTTP (#361), collection GET (#368), retry HTTP (#369), DTO (#358), or engine library (#356). + +## Authoritative sources + +National Academies of Sciences, Engineering, and Medicine. (2019). *Reproducibility and replicability in science*. The National Academies Press. https://doi.org/10.17226/25303 + +Wasserstein, R. L., & Lazar, N. A. (2016). The ASA statement on *p*-values: Context, process, and purpose. *The American Statistician, 70*(2), 129–133. https://doi.org/10.1080/00031305.2016.1154108 + +Wilson, E. B. (1927). Probable inference, the law of succession, and statistical inference. *Journal of the American Statistical Association, 22*(158), 209–212. https://doi.org/10.1080/01621459.1927.10502953 + +## Application + +The National Academies (2019) require that a computational procedure be runnable from the published interface, not only from an embedded library. Wasserstein and Lazar (2016) refuse to treat a passing threshold as automatic scientific authority, so the packaged listener produces the same `tepp.scientific_acceptance.v1` evidence as the library bind and never treats HTTP `200` as ADR 0014 promotion. Wilson (1927) supplies the coverage interval already implemented in `validation_core`. TEPP therefore moves `tepp-loopback` onto the engine wrapper, keeps temporal-context health checks, and still reports RMSE, bias, coverage, temporal order, and the SE-aware gate only after engine completion (National Academies of Sciences, Engineering, and Medicine, 2019; Wasserstein & Lazar, 2016; Wilson, 1927). Meredith (1993) remains unread (Unpaywall/OpenAlex 2026-08-31T11:00Z: `is_oa: false`, 0 locations). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread on the same terms (DOI `10.1007/bf02294457`). + +## Verification + +- the packaged binary returns `200` for one bounded temporal-context request; +- POST create then POST execute without `scientific_acceptance_json` then GET through the spawned binary returns `tepp.scientific_acceptance.v1`; +- Dockerfile builds `-p analysis_engine --bin tepp-loopback`.