Reconciles two independently-developed lines from base 006d3d0:
ours — M9/M10 community layers, retroactive purge + re-materialization,
signal revocation, agent capability boundaries, P1 feedback loop,
reason labels, instrumented metrics
theirs — M11/M12 cluster mode (tidal-net gRPC transport, tidal-server
cluster/scatter-gather, tidal-stress), multi-vector preference,
ANN candidate-gen, warm-tier day buckets, keyed signal snapshots
Notable semantic resolutions:
* storage::keys::Tag — both sides allocated 0x0E..0x11 for different
records. Kept theirs' 0x0E..0x1A (shipped on-disk format) and renumbered
ours to 0x1B..0x1E (CommunityMembership/Revocation/PurgeManifest/
CommunityLeave); Tag::ALL grown to 30 so the contiguity drift guard holds.
* ranking executor — took theirs' rewrite (SignalReadPlan pre-pass, keyed
SignalKey snapshots, Result-returning reads, finalize()) and re-applied
ours' M10 read suppression at the chokepoints it introduced:
single_signal_score, score_hot/trending/controversial,
CreatorEngagementRate, and the Stage-4 boost loop.
* signals::warm — theirs' day-bucket/read-time-rotation rewrite, with ours'
subtract_bucket and Clone extended to the new day tier; ours' test split
kept (warm/tests.rs, warm/proptests.rs) carrying theirs' updated bodies.
* db::signals — kept ours' contribution-logging try_cohort_attribution in
signal_dispatch.rs and theirs' event-time try_update_preference_vector;
dropped the superseded duplicates.
* db::mod / from_parts — theirs' constructors, with ours' purge/
re-materialization/revocation/community/skip-counter fields and restart
rebuilds; from_parts kept in its own file per the 600-line guideline.
* schema::validation::builders — ours' module split with theirs' expanded
tests; policy validation runs both sides' checks (read-signal lists +
profile overrides, then the zero-duration limit guard).
* feedback Unhide no longer writes a -1.0 "hide" signal: theirs' engine
rejects negative weights (spec §8). Reverses index state only, matching
every other undo action.
* SessionState::new is now the single construction path (gains
overrides_rejected/default_profile); AuditEntry gains kind on the
deserialize path, inferred from the accepted flag as before.
* Removed tidal/src/replication/tcp_transport.rs and its test: never
declared in replication/mod.rs on either branch, so it had never
compiled and nothing referenced it. Superseded by tidal-net's
GrpcTransport.
Verified: cargo clippy -p tidaldb (lib) clean; --all-targets compiles for
tidaldb/tidal-net/tidal-server/tidal-stress; 2094/2094 lib tests and the
integration suite pass except m8p3_reconcile_production's two CRDT-count
assertions, which fail identically on MERGE_HEAD (pre-existing).
tidalctl cannot build locally: its aws-sdk deps need rustc 1.91.1, local
toolchain is 1.91.0.
168 lines
5.6 KiB
Rust
168 lines
5.6 KiB
Rust
//! Report builder: aggregates per-group metrics from signal data.
|
|
//!
|
|
//! Scans `UserSignalIndex` for each user in the experiment to compute
|
|
//! CTR, completion rate, and return rate per group. The builder is
|
|
//! read-only -- it does not write to any storage.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use crate::entities::UserSignalIndex;
|
|
use crate::signals::SignalLedger;
|
|
|
|
use super::{
|
|
ExperimentConfig, ExperimentGroup, ExperimentReport, GroupMetrics, MetricLift, assign_group,
|
|
};
|
|
|
|
// ── ReportBuilder ────────────────────────────────────────────────────────────
|
|
|
|
/// Builds an `ExperimentReport` by scanning signal data for a set of users.
|
|
pub struct ReportBuilder<'a> {
|
|
ledger: &'a Arc<SignalLedger>,
|
|
user_signal_index: &'a UserSignalIndex,
|
|
}
|
|
|
|
impl<'a> ReportBuilder<'a> {
|
|
/// Create a new report builder with references to signal infrastructure.
|
|
#[must_use]
|
|
pub const fn new(
|
|
ledger: &'a Arc<SignalLedger>,
|
|
user_signal_index: &'a UserSignalIndex,
|
|
) -> Self {
|
|
Self {
|
|
ledger,
|
|
user_signal_index,
|
|
}
|
|
}
|
|
|
|
/// Build the experiment report for the given user population.
|
|
///
|
|
/// Each user in `user_ids` is assigned to treatment or control via
|
|
/// `assign_group`. Metrics are aggregated per group from signal data.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `TidalError::InvalidInput` if the config fails validation.
|
|
pub fn build(
|
|
&self,
|
|
config: &ExperimentConfig,
|
|
user_ids: &[u64],
|
|
) -> crate::Result<ExperimentReport> {
|
|
config.validate()?;
|
|
|
|
// Partition users into groups.
|
|
let mut treatment_users: Vec<u64> = Vec::new();
|
|
let mut control_users: Vec<u64> = Vec::new();
|
|
|
|
for &uid in user_ids {
|
|
match assign_group(uid, &config.experiment_id, config.treatment_fraction) {
|
|
ExperimentGroup::Treatment => treatment_users.push(uid),
|
|
ExperimentGroup::Control => control_users.push(uid),
|
|
}
|
|
}
|
|
|
|
// Aggregate metrics per group.
|
|
let treatment_metrics = self.aggregate_group_metrics(&treatment_users, config);
|
|
let control_metrics = self.aggregate_group_metrics(&control_users, config);
|
|
|
|
let lift = MetricLift::compute(&treatment_metrics, &control_metrics);
|
|
|
|
Ok(ExperimentReport {
|
|
experiment_id: config.experiment_id.clone(),
|
|
treatment_users: treatment_users.len(),
|
|
control_users: control_users.len(),
|
|
treatment_metrics,
|
|
control_metrics,
|
|
lift,
|
|
})
|
|
}
|
|
|
|
/// Aggregate engagement metrics for a group of users.
|
|
///
|
|
/// Uses `UserSignalIndex::user_signal_count` to sum signal counts per user
|
|
/// across all entities, and `user_activity_split` for return rate.
|
|
fn aggregate_group_metrics(&self, user_ids: &[u64], config: &ExperimentConfig) -> GroupMetrics {
|
|
let mut total_views: u64 = 0;
|
|
let mut total_clicks: u64 = 0;
|
|
let mut total_completions: u64 = 0;
|
|
let mut returned_users: u64 = 0;
|
|
let mut eligible_for_return: u64 = 0;
|
|
|
|
// Resolve signal type IDs once.
|
|
let view_type_id = self.ledger.resolve_signal_type("view").ok();
|
|
let click_type_ids: Vec<_> = config
|
|
.click_signals
|
|
.iter()
|
|
.filter_map(|s| self.ledger.resolve_signal_type(s).ok())
|
|
.collect();
|
|
let completion_type_ids: Vec<_> = config
|
|
.completion_signals
|
|
.iter()
|
|
.filter_map(|s| self.ledger.resolve_signal_type(s).ok())
|
|
.collect();
|
|
|
|
let now_ns = crate::schema::Timestamp::now().as_nanos();
|
|
|
|
for &user_id in user_ids {
|
|
// Sum views for this user across all entities.
|
|
if let Some(view_tid) = view_type_id {
|
|
total_views += self
|
|
.user_signal_index
|
|
.user_signal_count(user_id, view_tid, now_ns);
|
|
}
|
|
|
|
// Sum clicks for this user.
|
|
for &click_tid in &click_type_ids {
|
|
total_clicks += self
|
|
.user_signal_index
|
|
.user_signal_count(user_id, click_tid, now_ns);
|
|
}
|
|
|
|
// Sum completions for this user.
|
|
for &comp_tid in &completion_type_ids {
|
|
total_completions += self
|
|
.user_signal_index
|
|
.user_signal_count(user_id, comp_tid, now_ns);
|
|
}
|
|
|
|
// Return rate: user has old activity AND recent activity.
|
|
let (has_old, has_recent) = self.user_signal_index.user_activity_split(user_id, now_ns);
|
|
if has_old {
|
|
eligible_for_return += 1;
|
|
if has_recent {
|
|
returned_users += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::cast_precision_loss)]
|
|
let ctr = if total_views > 0 {
|
|
total_clicks as f64 / total_views as f64
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
#[allow(clippy::cast_precision_loss)]
|
|
let completion_rate = if total_views > 0 {
|
|
total_completions as f64 / total_views as f64
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
#[allow(clippy::cast_precision_loss)]
|
|
let return_rate = if eligible_for_return > 0 {
|
|
returned_users as f64 / eligible_for_return as f64
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
GroupMetrics {
|
|
ctr,
|
|
completion_rate,
|
|
return_rate,
|
|
total_views,
|
|
total_clicks,
|
|
total_completions,
|
|
}
|
|
}
|
|
}
|