feat(ml): re-enable OFI features from MBP-10 order book data

Wire 8 OFI features (OFI L1/L5, depth imbalance, VPIN, Kyle's lambda,
bid/ask slopes, trade imbalance) through the DQN training pipeline:

- Add mbp10_data_dir config field to DQNHyperparameters
- Dynamic state_dim: 43 (no OFI) or 51 (with OFI) based on config
- Compute OFI per bar during data loading, store on trainer
- Pass OFI features through regime_features slot in TradingState
- Configurable MBP-10 path with recursive .dbn/.dbn.zst discovery
- Add zstd auto-detection to DbnParser::parse_mbp10_file()
- Add --mbp10-data-dir CLI flag to train_baseline_rl
- Fix hardcoded [f64; 51] → FeatureVector51 ([f64; 40]) across
  examples, walk_forward, GPU memory profile, and test fixtures
- Fix stale state_dim=51 in dqn_config_2025() and DQN tests

2747 tests pass, 0 failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-07 13:00:07 +01:00
parent 5829d6378e
commit 8a87f302c0
15 changed files with 191 additions and 62 deletions

View File

@@ -0,0 +1,16 @@
# ES.FUT MBP-10 — small test subset (1 quarter)
# For local OFI pipeline validation only.
[universe]
name = "es-mbp10-test"
description = "ES.FUT MBP-10 test subset (Q2 2024)"
date_range_start = "2024-04-01"
date_range_end = "2024-07-01"
bar_size = "tick"
databento_dataset = "GLBX.MDP3"
databento_schema = "mbp-10"
[[symbols]]
symbol = "ES.FUT"
exchange = "GLBX.MDP3"
asset_class = "Future"

View File

@@ -656,9 +656,16 @@ impl DbnParser {
let path = path.as_ref();
info!("📖 Parsing MBP-10 file: {:?}", path);
// Open file and create DBN decoder
let file = File::open(path)?; // DataError::Io is automatically converted from std::io::Error
let reader = BufReader::new(file);
// Open file and create DBN decoder (auto-detect .dbn.zst vs .dbn)
let file = File::open(path)?;
let is_zstd = path.to_string_lossy().ends_with(".dbn.zst");
let reader: Box<dyn std::io::Read> = if is_zstd {
Box::new(zstd::Decoder::new(BufReader::new(file)).map_err(|e| {
DataError::InvalidFormat(format!("Failed to create zstd decoder: {}", e))
})?)
} else {
Box::new(BufReader::new(file))
};
let mut decoder = DbnDecoder::new(reader).map_err(|e| {
DataError::InvalidFormat(format!("Failed to create DBN decoder: {}", e))

View File

@@ -43,6 +43,7 @@ use tracing::{error, info, warn};
use common::metrics::{server as metrics_server, training_metrics as tm};
use ml::common::action::{ExposureLevel, FactoredAction, OrderType as ActionOrderType, Urgency};
use ml::dqn::{DQNConfig, OrderRouter, DQN};
use ml::trainers::dqn::FeatureVector51;
#[allow(unreachable_pub)]
mod baseline_common;
@@ -401,7 +402,7 @@ struct PortfolioState {
/// the START of the chunk. Market features are per-bar. Returns a contiguous
/// `Vec<f32>` of length `chunk_len * feature_dim`.
fn build_chunk_states(
test_features: &[[f64; 51]],
test_features: &[FeatureVector51],
test_bars: &[OHLCVBar],
chunk_start: usize,
chunk_end: usize,
@@ -542,7 +543,7 @@ fn simulate_chunk_trades(
#[allow(clippy::cognitive_complexity)]
fn evaluate_dqn_fold(
fold: usize,
test_features: &[[f64; 51]],
test_features: &[FeatureVector51],
test_bars: &[OHLCVBar],
models_dir: &Path,
args: &Args,
@@ -705,7 +706,7 @@ fn evaluate_dqn_fold(
/// for sequential trade simulation.
fn evaluate_ppo_fold(
fold: usize,
test_features: &[[f64; 51]],
test_features: &[FeatureVector51],
test_bars: &[OHLCVBar],
models_dir: &Path,
args: &Args,

View File

@@ -32,6 +32,7 @@ use tracing::{error, info, warn};
use common::metrics::{server as metrics_server, training_metrics as tm};
use ml::features::extraction::extract_ml_features;
use ml::trainers::dqn::FeatureVector51;
use ml::training::unified_trainer::UnifiedTrainable;
use ml::types::OHLCVBar;
use ml::walk_forward::{generate_walk_forward_windows, NormStats, WalkForwardConfig};
@@ -397,7 +398,7 @@ fn create_model(
fn evaluate_fold(
fold: usize,
model_name: &str,
test_features: &[[f64; 51]],
test_features: &[FeatureVector51],
test_bars: &[OHLCVBar],
args: &Args,
device: &Device,

View File

@@ -30,7 +30,7 @@ use tracing::{error, info, warn};
use ml::cuda_pipeline::DqnGpuData;
use ml::prelude::Device;
use ml::trainers::dqn::{DQNHyperparameters, DQNTrainer};
use ml::trainers::dqn::{DQNHyperparameters, DQNTrainer, FeatureVector51};
use ml::trainers::ppo::{PpoHyperparameters, PpoTrainer};
#[allow(unreachable_pub)]
@@ -140,6 +140,12 @@ struct Args {
/// model for each param set, saving as `dqn_ensemble_{k}_fold_{fold}.safetensors`.
#[arg(long, default_value_t = 1)]
ensemble_top_k: usize,
/// Optional path to MBP-10 order book data directory for OFI features.
/// When set, enables 8 OFI features (OFI L1/L5, depth imbalance, VPIN, etc.)
/// expanding state dimension from 43 to 51.
#[arg(long)]
mbp10_data_dir: Option<PathBuf>,
}
// ---------------------------------------------------------------------------
@@ -234,7 +240,7 @@ fn hp_bool(params: &Option<Value>, key: &str) -> Option<bool> {
// ---------------------------------------------------------------------------
/// Prepared fold data: (`train_features`, `val_features`, `train_bars`, `val_bars`)
type FoldData = (Vec<[f64; 51]>, Vec<[f64; 51]>, Vec<OHLCVBar>, Vec<OHLCVBar>);
type FoldData = (Vec<FeatureVector51>, Vec<FeatureVector51>, Vec<OHLCVBar>, Vec<OHLCVBar>);
#[allow(clippy::cognitive_complexity)]
/// Prepare a fold's data for training: extract features, normalize, align bars.
@@ -302,14 +308,14 @@ fn prepare_fold_data(
// ---------------------------------------------------------------------------
/// Convert pre-aligned features and bars into the format expected by
/// `DQNTrainer::train_with_preloaded_data`: `Vec<([f64; 51], Vec<f64>)>`.
/// `DQNTrainer::train_with_preloaded_data`: `Vec<(FeatureVector51, Vec<f64>)>`.
///
/// The `Vec<f64>` target contains OHLCV prices for the trainer's internal
/// reward computation and portfolio simulation.
fn features_to_trainer_format(
features: &[[f64; 51]],
features: &[FeatureVector51],
bars: &[OHLCVBar],
) -> Vec<([f64; 51], Vec<f64>)> {
) -> Vec<(FeatureVector51, Vec<f64>)> {
features
.iter()
.zip(bars.iter())
@@ -329,8 +335,8 @@ fn features_to_trainer_format(
#[allow(clippy::cognitive_complexity, clippy::too_many_arguments)]
fn train_dqn_fold(
fold: usize,
train_features: &[[f64; 51]],
val_features: &[[f64; 51]],
train_features: &[FeatureVector51],
val_features: &[FeatureVector51],
train_bars: &[OHLCVBar],
val_bars: &[OHLCVBar],
args: &Args,
@@ -438,6 +444,7 @@ fn train_dqn_fold(
weight_decay: hp_f64(hp, "weight_decay").unwrap_or(1e-4),
kelly_fractional: hp_f64(hp, "kelly_fractional").unwrap_or(0.5),
kelly_max_fraction: hp_f64(hp, "kelly_max_fraction").unwrap_or(0.25),
mbp10_data_dir: args.mbp10_data_dir.as_ref().map(|p| p.to_string_lossy().into_owned()),
..DQNHyperparameters::default()
};
@@ -509,8 +516,8 @@ fn train_dqn_fold(
#[allow(clippy::cognitive_complexity)]
fn train_ppo_fold(
fold: usize,
train_features: &[[f64; 51]],
val_features: &[[f64; 51]],
train_features: &[FeatureVector51],
val_features: &[FeatureVector51],
train_bars: &[OHLCVBar],
_val_bars: &[OHLCVBar],
args: &Args,
@@ -585,7 +592,7 @@ fn train_ppo_fold(
None, // num_envs: standard single-env collection
).context("Failed to create PpoTrainer")?;
// Convert features from [f64; 51] to Vec<Vec<f32>> for the trainer
// Convert features from FeatureVector51 to Vec<Vec<f32>> for the trainer
let market_data: Vec<Vec<f32>> = train_features
.iter()
.map(|feat| feat.iter().map(|&v| v as f32).collect())

View File

@@ -35,6 +35,7 @@ use serde_json::Value;
use tracing::{error, info, warn};
use ml::features::extraction::extract_ml_features;
use ml::trainers::dqn::FeatureVector51;
use ml::training::unified_trainer::UnifiedTrainable;
use ml::types::OHLCVBar;
use ml::walk_forward::{generate_walk_forward_windows, NormStats, WalkForwardConfig};
@@ -400,7 +401,7 @@ fn prepare_fold_data(
/// Convert normalized features + bars into (input, target) tensor pairs.
fn build_tensor_pairs(
norm_features: &[[f64; 51]],
norm_features: &[FeatureVector51],
bars: &[OHLCVBar],
bar_offset: usize,
args: &Args,

View File

@@ -93,7 +93,7 @@ pub struct DqnGpuData {
impl DqnGpuData {
/// Upload DQN training data to GPU as f32 tensors.
///
/// Converts `[f64; 51]` features and `Vec<f64>` targets to f32,
/// Converts `[f64; 40]` features and `Vec<f64>` targets to f32,
/// flattens into contiguous arrays, and uploads once.
pub fn upload(
data: &[([f64; 40], Vec<f64>)],

View File

@@ -3755,16 +3755,16 @@ mod tests {
let mut config = DQNConfig::emergency_safe_defaults();
config.min_replay_size = 4;
config.batch_size = 4;
config.state_dim = 51; // Match production feature vector size (45 market + 6 portfolio)
config.state_dim = 43; // Match production feature vector size (40 market + 3 portfolio)
let mut dqn = DQN::new(config)?;
// Add enough experiences
for i in 0..10 {
let experience = Experience::new(
vec![i as f32 * 0.1; 51],
vec![i as f32 * 0.1; 43],
(i % 3) as u8,
i as f32,
vec![(i + 1) as f32 * 0.1; 51],
vec![(i + 1) as f32 * 0.1; 43],
i == 9,
);
dqn.store_experience(experience)?;

View File

@@ -95,7 +95,7 @@ pub mod estimates {
activation_multiplier: 2.5,
supports_checkpointing: false,
default_seq_len: 128,
default_feature_dim: 51,
default_feature_dim: 43,
};
pub const LIQUID: ModelMemoryEstimate = ModelMemoryEstimate {

View File

@@ -2497,6 +2497,9 @@ impl HyperparameterOptimizable for DQNTrainer {
use_dsr: true,
dsr_eta: params.dsr_eta,
// MBP-10 data directory for OFI features (None = no OFI in hyperopt)
mbp10_data_dir: None,
// Mixed precision: auto-detect from GPU hardware
mixed_precision: {
match crate::memory_optimization::auto_batch_size::detect_gpu_memory() {

View File

@@ -796,6 +796,12 @@ pub struct DQNHyperparameters {
pub use_dsr: bool,
/// DSR EMA decay rate (0.001=slow, 0.05=fast). Tunable in hyperopt.
pub dsr_eta: f64,
// OFI (Order Flow Imbalance) features from MBP-10 data
/// Directory containing MBP-10 .dbn.zst files for OFI feature extraction.
/// When Some, 8 OFI features are computed and appended to the state (state_dim += 8).
/// When None, no OFI features are used (backward compatible).
pub mbp10_data_dir: Option<String>,
}
impl Default for DQNHyperparameters {
@@ -994,6 +1000,9 @@ impl DQNHyperparameters {
// DSR: disabled by default (opt-in via hyperopt)
use_dsr: false,
dsr_eta: 0.01, // ~100-step effective EMA window
// OFI: disabled by default (requires MBP-10 data)
mbp10_data_dir: None,
}
}
}
@@ -1046,7 +1055,7 @@ use crate::dqn::dqn::DQNConfig;
pub(crate) fn dqn_config_2025() -> DQNConfig {
DQNConfig {
// Network Architecture
state_dim: 51, // 45 market features + 6 portfolio features (Wave 23)
state_dim: 43, // 40 market features + 3 portfolio features
num_actions: 5, // 5 exposure levels (Short100, Short50, Flat, Long50, Long100)
hidden_dims: vec![512, 256, 128], // Sufficient capacity without overfitting (Trial 19)
leaky_relu_alpha: 0.01,

View File

@@ -375,20 +375,36 @@ impl DQNTrainer {
// WAVE 2-A2: Load MBP-10 snapshots for OFI calculation
use data::providers::databento::dbn_parser::DbnParser;
let mbp10_dir = Path::new("test_data/mbp10");
let mbp10_snapshots = if mbp10_dir.exists() {
info!("📊 Loading MBP-10 order book snapshots for OFI calculation...");
// Load all .dbn files in the directory
let mut all_snapshots = Vec::new();
if let Ok(entries) = std::fs::read_dir(mbp10_dir) {
let parser = DbnParser::new()
.context("Failed to create DBN parser for MBP-10 data")?;
let mbp10_dir_path = self.hyperparams.mbp10_data_dir.as_ref().map(Path::new);
let mbp10_snapshots = if let Some(mbp10_dir) = mbp10_dir_path {
if mbp10_dir.exists() {
info!("📊 Loading MBP-10 order book snapshots from {:?}...", mbp10_dir);
let mut all_snapshots = Vec::new();
// Recursive file discovery (data may be in symbol subdirectories)
fn collect_dbn_files(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_dbn_files(&path, out);
} else {
let name = path.to_string_lossy();
if name.ends_with(".dbn") || name.ends_with(".dbn.zst") {
out.push(path);
}
}
}
}
}
let mut dbn_files = Vec::new();
collect_dbn_files(mbp10_dir, &mut dbn_files);
dbn_files.sort();
for entry in entries.flatten() {
let path = entry.path();
// Only load .dbn files (not .zst compressed files)
if path.extension().and_then(|s| s.to_str()) == Some("dbn") {
match parser.parse_mbp10_file(&path).await {
if !dbn_files.is_empty() {
let parser = DbnParser::new()
.context("Failed to create DBN parser for MBP-10 data")?;
for path in &dbn_files {
match parser.parse_mbp10_file(path).await {
Ok(mut snaps) => {
info!(" ✅ Loaded {} snapshots from {:?}", snaps.len(), path.file_name());
all_snapshots.append(&mut snaps);
@@ -399,31 +415,73 @@ impl DQNTrainer {
}
}
}
}
if all_snapshots.is_empty() {
warn!("⚠️ No MBP-10 snapshots loaded. OFI features will be zeros.");
None
if all_snapshots.is_empty() {
warn!("⚠️ No MBP-10 snapshots loaded. OFI features will be zeros.");
None
} else {
all_snapshots.sort_by_key(|s| s.timestamp);
info!("✅ Total MBP-10 snapshots loaded: {} (sorted by timestamp)", all_snapshots.len());
Some(all_snapshots)
}
} else {
// Sort snapshots by timestamp for efficient lookup
all_snapshots.sort_by_key(|s| s.timestamp);
info!("✅ Total MBP-10 snapshots loaded: {} (sorted by timestamp)", all_snapshots.len());
Some(all_snapshots)
warn!("⚠️ MBP-10 directory not found at {:?}. OFI features disabled.", mbp10_dir);
None
}
} else {
warn!(" MBP-10 directory not found at {:?}. OFI features will be zeros.", mbp10_dir);
info!(" No MBP-10 data directory configured. OFI features disabled.");
None
};
// Extract 54-feature vectors (technical indicators, OFI, time, statistical features)
info!("Extracting 51-feature vectors from OHLCV bars (51-feature architecture)...");
// Extract 40-feature vectors (technical indicators, time, statistical features)
info!("Extracting 40-feature vectors from OHLCV bars...");
let feature_vectors = self.extract_full_features(&all_ohlcv_bars, mbp10_snapshots.as_deref())?;
info!(
"Extracted {} feature vectors (51 dimensions: technical indicators, time, statistical features (Proxy OFI removed))",
"Extracted {} feature vectors (40 dimensions: technical indicators, time, statistical features)",
feature_vectors.len()
);
// Compute OFI features from MBP-10 snapshots (8 features per bar)
if let Some(ref snapshots) = mbp10_snapshots {
use crate::features::ofi_calculator::OFICalculator;
use crate::features::mbp10_loader::get_snapshots_for_timestamp;
info!("📊 Computing OFI features from {} MBP-10 snapshots...", snapshots.len());
let mut ofi_calculator = OFICalculator::new();
let mut ofi_per_bar = Vec::with_capacity(feature_vectors.len());
const WARMUP: usize = 50;
for i in 0..feature_vectors.len() {
let bar = &all_ohlcv_bars[i + WARMUP];
let bar_ts = bar.timestamp.timestamp_nanos_opt().unwrap_or(0) as u64;
// Find the nearest MBP-10 snapshot for this bar's timestamp
let window = get_snapshots_for_timestamp(snapshots, bar_ts, 1);
if let Some(snap) = window.first() {
match ofi_calculator.calculate(snap) {
Ok(features) => {
let arr = features.to_array();
if features.is_valid() {
ofi_per_bar.push(arr);
} else {
ofi_per_bar.push([0.0; 8]);
}
}
Err(_) => ofi_per_bar.push([0.0; 8]),
}
} else {
ofi_per_bar.push([0.0; 8]);
}
}
let non_zero = ofi_per_bar.iter().filter(|f| f.iter().any(|&v| v != 0.0)).count();
info!("✅ OFI features computed: {} total, {} non-zero ({:.1}%)",
ofi_per_bar.len(), non_zero,
if ofi_per_bar.is_empty() { 0.0 } else { non_zero as f64 / ofi_per_bar.len() as f64 * 100.0 });
self.ofi_features = Some(ofi_per_bar);
}
// Create training data pairs (features, target)
// Target: [preprocessed_current, preprocessed_next, raw_current, raw_next]
// WAVE 3 BUG FIX: Include raw prices for triple barrier tracker (needs actual market prices in cents)
@@ -547,13 +605,12 @@ impl DQNTrainer {
all_ohlcv_bars.sort_by_key(|bar| bar.timestamp);
debug!("Bars sorted successfully");
// Extract 54-feature vectors (technical indicators, Proxy OFI, time, statistical features)
// Note: DBN loader does not load MBP-10 data, so OFI features will be zeros
info!("Extracting 51-feature vectors from OHLCV bars (51-feature architecture)...");
// Extract 40-feature vectors (technical indicators, time, statistical features)
info!("Extracting 40-feature vectors from OHLCV bars...");
let feature_vectors = self.extract_full_features(&all_ohlcv_bars, None)?;
info!(
"Extracted {} feature vectors (51 dimensions: technical indicators, time, statistical features (Proxy OFI removed))",
"Extracted {} feature vectors (40 dimensions: technical indicators, time, statistical features)",
feature_vectors.len()
);

View File

@@ -234,6 +234,11 @@ pub struct DQNTrainer {
/// Multi-GPU configuration for data-parallel training (None = single GPU)
multi_gpu: Option<crate::cuda_pipeline::multi_gpu::MultiGpuConfig>,
/// Pre-computed OFI features per bar (indexed by training data position)
/// Populated during data loading when MBP-10 order book data is available.
/// Passed as `regime_features` in `TradingState::from_normalized()`.
pub(crate) ofi_features: Option<Vec<[f64; 8]>>,
}
impl std::fmt::Debug for DQNTrainer {
@@ -383,8 +388,11 @@ impl DQNTrainer {
// Create DQN configuration
// 40-feature architecture: OHLCV, technical, patterns, volume, time, statistical
// Portfolio features (3) are populated via PortfolioTracker → 43 total state_dim
// With MBP-10 OFI features: +8 regime features → 51 total
let ofi_enabled = hyperparams.mbp10_data_dir.is_some();
let state_dim = if ofi_enabled { 51 } else { 43 };
let config = DQNConfig {
state_dim: 43, // 43-feature vectors: 40 market features + 3 portfolio
state_dim,
num_actions: 5, // 5 exposure levels (Short100, Short50, Flat, Long50, Long100)
hidden_dims,
learning_rate: hyperparams.learning_rate,
@@ -863,6 +871,9 @@ impl DQNTrainer {
// Multi-GPU: auto-detected data parallelism
multi_gpu,
// OFI features: populated during data loading when MBP-10 data is available
ofi_features: None,
})
}
@@ -1890,7 +1901,7 @@ impl DQNTrainer {
};
let close_price = rust_decimal::Decimal::try_from(current_close)
.unwrap_or(rust_decimal::Decimal::ZERO);
self.feature_vector_to_state(&training_data[i].0, Some(close_price))
self.feature_vector_to_state_with_ofi(&training_data[i].0, Some(close_price), Some(i))
})
.collect();
(Some(bt), cpu_states?)
@@ -1907,7 +1918,7 @@ impl DQNTrainer {
};
let close_price = rust_decimal::Decimal::try_from(current_close)
.unwrap_or(rust_decimal::Decimal::ZERO);
self.feature_vector_to_state(&training_data[i].0, Some(close_price))
self.feature_vector_to_state_with_ofi(&training_data[i].0, Some(close_price), Some(i))
})
.collect();
(None, cpu_states?)
@@ -1989,9 +2000,10 @@ impl DQNTrainer {
let mut next_state = if i + 1 < training_data.len() {
let next_close_price = rust_decimal::Decimal::try_from(next_close)
.unwrap_or(rust_decimal::Decimal::ZERO);
self.feature_vector_to_state(
self.feature_vector_to_state_with_ofi(
&training_data[i + 1].0,
Some(next_close_price),
Some(i + 1),
)?
} else {
state.clone()
@@ -3263,6 +3275,15 @@ impl DQNTrainer {
&self,
feature_vec: &FeatureVector51,
close_price: Option<rust_decimal::Decimal>,
) -> Result<TradingState> {
self.feature_vector_to_state_with_ofi(feature_vec, close_price, None)
}
fn feature_vector_to_state_with_ofi(
&self,
feature_vec: &FeatureVector51,
close_price: Option<rust_decimal::Decimal>,
ofi_index: Option<usize>,
) -> Result<TradingState> {
// States are pre-normalized during data loading
let normalized_features: Vec<f32> = feature_vec.iter().map(|&v| v as f32).collect();
@@ -3300,8 +3321,14 @@ impl DQNTrainer {
vec![0.0, 0.0, 0.0] // Fallback if no price provided
};
// 43-FEATURE ARCHITECTURE: No regime features (removed for feature reduction)
let regime_features: Vec<f32> = vec![];
// OFI regime features: 8 features from MBP-10 order book data (or empty if unavailable)
let regime_features: Vec<f32> = if let (Some(ofi), Some(idx)) = (&self.ofi_features, ofi_index) {
ofi.get(idx)
.map(|f| f.iter().map(|&v| v as f32).collect())
.unwrap_or_default()
} else {
vec![]
};
// Use from_normalized() to preserve sign information
Ok(TradingState::from_normalized(

View File

@@ -213,8 +213,8 @@ pub struct NormStats {
pub std: Vec<f64>,
}
/// Number of features in a standard feature vector.
const FEATURE_DIM: usize = 51;
/// Number of features in a standard feature vector (40 market features).
const FEATURE_DIM: usize = 40;
/// Minimum standard deviation to prevent division by zero.
const MIN_STD: f64 = 1e-8;

View File

@@ -129,7 +129,7 @@ async fn load_features_from_cache(
_parquet_path: &Path,
_mbp10_dir: &Path,
_warmup_period: usize,
) -> Result<Option<(Vec<([f64; 51], Vec<f64>)>, Vec<([f64; 51], Vec<f64>)>)>> {
) -> Result<Option<(Vec<([f64; 40], Vec<f64>)>, Vec<([f64; 40], Vec<f64>)>)>> {
// NOTE: This is a stub that will fail until implemented
// Implementation should follow design doc section 4 (Phase 2)