Skip to content

Commit ff56ff8

Browse files
feat(ohh): add streaming reader and anonymizer (#278)
- `HandReader`: streaming iterator over OHH records from a file, directory, or any `BufRead`. Parse errors don't halt iteration; directory-mode errors are annotated with the offending file path. - `anonymize` module with a configurable `Anonymizer` that rewrites player names (per-hand or stream-stable), site/network/version, table names, game/tournament numbers, and UTC timestamps. Streaming driver `anonymize_stream` handles multi-gigabyte inputs in O(one hand) memory. - `write_hand` factored out of `append_hand` so the streaming anonymizer and the file appender share the same storage-format emitter. - `ohh_files_in_dir` helper; `HandStore` now calls it instead of rolling its own directory scan. Adds the `rsp ohh anonymize` CLI command as a thin wrapper over the library, and removes the duplicate `src/bin/rsp/ohh/reader.rs` now that `view.rs` consumes the library reader directly.
1 parent 0e538ec commit ff56ff8

22 files changed

Lines changed: 2779 additions & 198 deletions

src/bin/rsp/arena/compare.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ pub struct CompareArgs {
6868
/// When omitted, CFR agents run sequentially.
6969
#[arg(long)]
7070
parallel: Option<usize>,
71+
72+
#[command(flatten)]
73+
tui: TuiFlags,
7174
}
7275

7376
fn build_comparison(args: &CompareArgs) -> Result<ArenaComparison, CompareError> {
@@ -235,11 +238,13 @@ fn run_comparison_with_tui(
235238
Ok(())
236239
}
237240

238-
pub fn run(mut args: CompareArgs, tui_flags: &TuiFlags) -> Result<(), CompareError> {
241+
pub fn run(mut args: CompareArgs) -> Result<(), CompareError> {
242+
let use_tui = args.tui.should_use_tui();
243+
239244
// When using the TUI without an explicit output dir, use a temp dir
240245
// so OHH hands are always written and game detail view works.
241246
let _temp_dir;
242-
if args.output_dir.is_none() && tui_flags.should_use_tui() {
247+
if args.output_dir.is_none() && use_tui {
243248
let tmp = tempfile::TempDir::new()?;
244249
args.output_dir = Some(tmp.path().to_path_buf());
245250
_temp_dir = Some(tmp);
@@ -249,7 +254,7 @@ pub fn run(mut args: CompareArgs, tui_flags: &TuiFlags) -> Result<(), CompareErr
249254

250255
let comparison = build_comparison(&args)?;
251256

252-
if tui_flags.should_use_tui() {
257+
if use_tui {
253258
comparison.print_configuration_summary();
254259
run_comparison_with_tui(comparison, args.big_blind)
255260
} else {

src/bin/rsp/arena/generate.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@ pub struct GenerateArgs {
9696
/// Optional random seed for reproducibility
9797
#[arg(short = 's', long = "seed")]
9898
seed: Option<u64>,
99+
100+
#[command(flatten)]
101+
tui: TuiFlags,
99102
}
100103

101104
impl GenerateArgs {
@@ -507,7 +510,7 @@ fn run_generation_with_tui(
507510
Ok(())
508511
}
509512

510-
pub fn run(args: GenerateArgs, tui_flags: &TuiFlags) -> Result<(), GenerateError> {
513+
pub fn run(args: GenerateArgs) -> Result<(), GenerateError> {
511514
let configs = load_configs(&args.agents_dir)?;
512515
if configs.is_empty() {
513516
return Err(GenerateError::NoConfigs(
@@ -518,7 +521,7 @@ pub fn run(args: GenerateArgs, tui_flags: &TuiFlags) -> Result<(), GenerateError
518521

519522
args.validate()?;
520523

521-
if tui_flags.should_use_tui() {
524+
if args.tui.should_use_tui() {
522525
run_generation_with_tui(args, configs)
523526
} else {
524527
run_generation(&args, &configs)

src/bin/rsp/arena/mod.rs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
use clap::{Args, Subcommand};
22

3-
use crate::tui::TuiFlags;
4-
53
pub mod charts;
64
pub mod compare;
75
pub mod generate;
@@ -37,11 +35,11 @@ pub enum ArenaError {
3735
Verify(#[from] verify::VerifyError),
3836
}
3937

40-
pub fn run(args: ArenaArgs, tui_flags: &TuiFlags) -> Result<(), ArenaError> {
38+
pub fn run(args: ArenaArgs) -> Result<(), ArenaError> {
4139
match args.command {
4240
ArenaCommand::Charts(a) => charts::run(a)?,
43-
ArenaCommand::Compare(a) => compare::run(a, tui_flags)?,
44-
ArenaCommand::Generate(a) => generate::run(a, tui_flags)?,
41+
ArenaCommand::Compare(a) => compare::run(a)?,
42+
ArenaCommand::Generate(a) => generate::run(a)?,
4543
ArenaCommand::Verify(a) => verify::run(a)?,
4644
}
4745
Ok(())

src/bin/rsp/main.rs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,13 @@ mod tui;
2525

2626
use clap::{Parser, Subcommand};
2727
use common::TracingArgs;
28-
use tui::TuiFlags;
2928

3029
#[derive(Parser)]
3130
#[command(name = "rsp", about = "A poker toolkit")]
3231
struct Cli {
3332
#[command(flatten)]
3433
tracing: TracingArgs,
3534

36-
#[command(flatten)]
37-
tui: TuiFlags,
38-
3935
#[command(subcommand)]
4036
command: Commands,
4137
}
@@ -74,10 +70,10 @@ fn main() -> Result<(), CliError> {
7470

7571
match cli.command {
7672
Commands::Holdem(args) => holdem::run(args)?,
77-
Commands::Arena(args) => arena::run(args, &cli.tui)?,
73+
Commands::Arena(args) => arena::run(args)?,
7874
Commands::Omaha(args) => omaha::run(args)?,
7975
Commands::Icm(args) => icm::run(args)?,
80-
Commands::Ohh(args) => ohh::run(args, &cli.tui)?,
76+
Commands::Ohh(args) => ohh::run(args)?,
8177
}
8278
Ok(())
8379
}

src/bin/rsp/ohh/anonymize.rs

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
//! `rsp ohh anonymize` — stream-anonymize an OHH hand-history file.
2+
//!
3+
//! This module is intentionally thin: it maps CLI flags onto an
4+
//! [`AnonymizeConfig`], opens I/O streams, and delegates the actual
5+
//! work to [`rs_poker::open_hand_history::anonymize::anonymize_stream`].
6+
use std::fs::File;
7+
use std::io::{self, BufRead, BufReader, BufWriter, Write};
8+
use std::path::PathBuf;
9+
use std::time::Duration;
10+
11+
use clap::{Args, ValueEnum};
12+
use rs_poker::open_hand_history::anonymize::{
13+
AnonymizeConfig, Anonymizer, NameStrategy, StreamError, TimeFuzzConfig, anonymize_stream,
14+
};
15+
16+
/// Name-strategy choices exposed on the command line.
17+
///
18+
/// Mirrors [`NameStrategy`] but has its own `clap::ValueEnum` so the
19+
/// library type doesn't take on a CLI dependency.
20+
#[derive(Debug, Clone, Copy, ValueEnum)]
21+
enum NameStrategyArg {
22+
/// Preserve original player names.
23+
Keep,
24+
/// Random names per-hand; cross-hand identity is lost.
25+
PerHand,
26+
/// Stable names across the whole stream (default).
27+
Stable,
28+
}
29+
30+
impl From<NameStrategyArg> for NameStrategy {
31+
fn from(v: NameStrategyArg) -> Self {
32+
match v {
33+
NameStrategyArg::Keep => NameStrategy::Keep,
34+
NameStrategyArg::PerHand => NameStrategy::PerHand,
35+
NameStrategyArg::Stable => NameStrategy::Stable,
36+
}
37+
}
38+
}
39+
40+
/// Anonymize an Open Hand History file.
41+
///
42+
/// Reads a JSONL `.ohh` file (or stdin with `-`) and writes an
43+
/// anonymized copy (or stdout with `-`). Memory usage stays O(one
44+
/// hand) regardless of input size.
45+
#[derive(Args, Debug)]
46+
pub struct AnonymizeArgs {
47+
/// Input `.ohh` file, or `-` for stdin.
48+
input: PathBuf,
49+
50+
/// Output `.ohh` file, or `-` for stdout.
51+
#[arg(short, long, default_value = "-")]
52+
output: PathBuf,
53+
54+
/// How to replace player names.
55+
#[arg(long, value_enum, default_value_t = NameStrategyArg::Stable)]
56+
names: NameStrategyArg,
57+
58+
/// Disable site-name rotation.
59+
#[arg(long)]
60+
keep_site: bool,
61+
62+
/// Disable network-name rotation.
63+
#[arg(long)]
64+
keep_network: bool,
65+
66+
/// Disable internal-version rotation.
67+
#[arg(long)]
68+
keep_version: bool,
69+
70+
/// Disable table-name rotation.
71+
#[arg(long)]
72+
keep_tables: bool,
73+
74+
/// Disable game-number / tournament-number / tournament-name
75+
/// rotation.
76+
#[arg(long)]
77+
keep_game_numbers: bool,
78+
79+
/// Disable timestamp fuzzing entirely.
80+
#[arg(long)]
81+
keep_times: bool,
82+
83+
/// Maximum absolute global time shift, in minutes.
84+
#[arg(long, default_value_t = 30)]
85+
shift_minutes: u64,
86+
87+
/// Maximum absolute per-hand jitter, in seconds.
88+
#[arg(long, default_value_t = 5)]
89+
jitter_seconds: u64,
90+
91+
/// Optional seed for reproducible output.
92+
#[arg(long)]
93+
seed: Option<u64>,
94+
}
95+
96+
/// Errors surfaced by `rsp ohh anonymize`.
97+
#[derive(Debug, thiserror::Error)]
98+
pub enum AnonymizeError {
99+
/// Opening the input or output file failed.
100+
#[error("I/O error: {0}")]
101+
Io(#[from] io::Error),
102+
/// The underlying [`anonymize_stream`] driver returned an error.
103+
#[error(transparent)]
104+
Stream(#[from] StreamError),
105+
}
106+
107+
/// Entry point invoked from [`crate::ohh::run`].
108+
pub fn run(args: AnonymizeArgs) -> Result<(), AnonymizeError> {
109+
let config = build_config(&args);
110+
let mut anonymizer = Anonymizer::new(config);
111+
112+
let input: Box<dyn BufRead> = open_input(&args.input)?;
113+
let mut output: Box<dyn Write> = open_output(&args.output)?;
114+
115+
let count = anonymize_stream(input, &mut output, &mut anonymizer)?;
116+
output.flush()?;
117+
118+
eprintln!("anonymized {count} hand(s)");
119+
Ok(())
120+
}
121+
122+
/// Translate CLI flags into an [`AnonymizeConfig`].
123+
fn build_config(args: &AnonymizeArgs) -> AnonymizeConfig {
124+
let time_fuzz = if args.keep_times {
125+
None
126+
} else {
127+
Some(TimeFuzzConfig {
128+
max_global_shift: Duration::from_secs(args.shift_minutes * 60),
129+
max_per_hand_jitter: Duration::from_secs(args.jitter_seconds),
130+
})
131+
};
132+
133+
AnonymizeConfig {
134+
name_strategy: args.names.into(),
135+
name_pool: None,
136+
rotate_site: !args.keep_site,
137+
rotate_network: !args.keep_network,
138+
rotate_internal_version: !args.keep_version,
139+
rotate_table_name: !args.keep_tables,
140+
rotate_game_numbers: !args.keep_game_numbers,
141+
time_fuzz,
142+
seed: args.seed,
143+
}
144+
}
145+
146+
/// Open an input path, treating `-` as stdin.
147+
fn open_input(path: &PathBuf) -> io::Result<Box<dyn BufRead>> {
148+
if path.as_os_str() == "-" {
149+
Ok(Box::new(BufReader::new(io::stdin().lock())))
150+
} else {
151+
Ok(Box::new(BufReader::new(File::open(path)?)))
152+
}
153+
}
154+
155+
/// Open an output path, treating `-` as stdout.
156+
fn open_output(path: &PathBuf) -> io::Result<Box<dyn Write>> {
157+
if path.as_os_str() == "-" {
158+
Ok(Box::new(BufWriter::new(io::stdout().lock())))
159+
} else {
160+
Ok(Box::new(BufWriter::new(File::create(path)?)))
161+
}
162+
}

src/bin/rsp/ohh/mod.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
pub mod reader;
1+
pub mod anonymize;
22
pub mod stats;
33
pub mod view;
44

55
use clap::{Args, Subcommand};
66

7-
use crate::tui::TuiFlags;
8-
97
#[derive(Args)]
108
pub struct OhhArgs {
119
#[command(subcommand)]
@@ -16,17 +14,22 @@ pub struct OhhArgs {
1614
enum OhhCommand {
1715
/// View hand history file or directory with interactive TUI
1816
View(view::ViewArgs),
17+
/// Anonymize an OHH hand history stream
18+
Anonymize(anonymize::AnonymizeArgs),
1919
}
2020

2121
#[derive(Debug, thiserror::Error)]
2222
pub enum OhhError {
2323
#[error(transparent)]
2424
View(#[from] view::ViewError),
25+
#[error(transparent)]
26+
Anonymize(#[from] anonymize::AnonymizeError),
2527
}
2628

27-
pub fn run(args: OhhArgs, tui_flags: &TuiFlags) -> Result<(), OhhError> {
29+
pub fn run(args: OhhArgs) -> Result<(), OhhError> {
2830
match args.command {
29-
OhhCommand::View(a) => view::run(a, tui_flags)?,
31+
OhhCommand::View(a) => view::run(a)?,
32+
OhhCommand::Anonymize(a) => anonymize::run(a)?,
3033
}
3134
Ok(())
3235
}

0 commit comments

Comments
 (0)