Algorithm-Classifier-IsolationForest

 view release on metacpan or  search on metacpan

t/90-cli-commands.t  view on Meta::CPAN

		my @a = do { open my $fh, '<', $pred_csv;  <$fh> };
		my @b = do { open my $fh, '<', $pred2_csv; <$fh> };
		is( scalar @a, scalar @b, 'same row count' );

		my $diffs = 0;
		for my $i ( 0 .. $#a ) {
			chomp( my $la = $a[$i] );
			chomp( my $lb = $b[$i] );
			my ( $sa, $ya ) = split /,/, $la;
			my ( $sb, $yb ) = split /,/, $lb;
			$diffs++ if abs( $sa - $sb ) > 1e-9;
			$diffs++ if $ya != $yb;
		}
		is( $diffs, 0, 'packed-input scores and labels match CSV-input output' );
	}; ## end 'predict on packed input agrees with CSV input' => sub

	subtest 'predict with -d works on packed input' => sub {
		my $out = `$^X -Ilib $bin predict -m $model -i $packed -d 2>&1`;
		is( $?, 0, 'predict -d (packed) exits 0' );
		my @lines = split /\n/, $out;
		is( scalar @lines, 7, 'one row per query point' );
		my $first = $lines[0];
		my @f     = split /,/, $first;
		is( scalar @f, 5, '-d output has 3 features + score + label (5 columns)' );
	};
} ## end SKIP:

# --- online model workflow: stream + info -------------------------------
{
	my $stream_csv = "$tmp/stream.csv";
	my $omodel     = "$tmp/online_model.json";
	my $oscores    = "$tmp/stream_scores.csv";

	{
		open my $fh, '>', $stream_csv or die $!;
		srand(3);
		for ( 1 .. 120 ) {
			print $fh join( ',', map { sprintf( '%.4f', rand() - 0.5 ) } 1 .. 3 ), "\n";
		}
		print $fh "9,9,9\n";
		close $fh;
	}

	subtest 'stream creates an online model and emits prequential scores' => sub {
		my $out = `$^X -Ilib $bin stream -i $stream_csv -m $omodel -n 20 --window 64 --eta 16 -s 42 2>&1`;
		is( $?, 0, 'stream exits 0' );
		ok( -s $omodel, 'online model was written' );
		my @lines = split /\n/, $out;
		is( scalar @lines, 121, 'one output row per input row' );
		like( $lines[-1], qr/^[\d.eE+-]+,[01]$/, 'rows match "score,label"' );
	};

	subtest 'stream resumes a saved model' => sub {
		my $out = `$^X -Ilib $bin stream -i $stream_csv -m $omodel 2>&1`;
		is( $?, 0, 'stream (resume) exits 0' );
		my @lines = split /\n/, $out;
		is( scalar @lines, 121, 'one output row per input row on resume' );
	};

	subtest 'stream --score-only does not advance the model' => sub {
		my $before = do { local ( @ARGV, $/ ) = ($omodel); <> };
		my $out    = `$^X -Ilib $bin stream --score-only -i $stream_csv -m $omodel -o $oscores 2>&1`;
		is( $?, 0, 'stream --score-only exits 0' );
		ok( -s $oscores, 'score output file written' );
		my $after = do { local ( @ARGV, $/ ) = ($omodel); <> };
		is( $after, $before, 'model file unchanged by --score-only' );
	};

	subtest 'stream --learn-only emits nothing but updates the model' => sub {
		my $before = do { local ( @ARGV, $/ ) = ($omodel); <> };
		my $out    = `$^X -Ilib $bin stream --learn-only -i $stream_csv -m $omodel 2>&1`;
		is( $?,   0,  'stream --learn-only exits 0' );
		is( $out, '', 'no score output' );
		my $after = do { local ( @ARGV, $/ ) = ($omodel); <> };
		isnt( $after, $before, 'model file advanced by --learn-only' );
	};

	subtest 'info recognises an online model' => sub {
		my $out = `$^X -Ilib $bin info -m $omodel 2>&1`;
		is( $?, 0, 'info exits 0 on an online model' );
		like( $out, qr/type\s+online/,          'info reports type=online' );
		like( $out, qr/window_size\s+64/,       'info reports window_size' );
		like( $out, qr/max_leaf_samples\s+16/,  'info reports max_leaf_samples' );
		like( $out, qr/tree_total_nodes\s+\d+/, 'info reports tree stats' );
	};

	subtest 'info --json on an online model parses' => sub {
		my $out = `$^X -Ilib $bin info -m $omodel --json 2>&1`;
		is( $?, 0, 'info --json exits 0' );
		require JSON::PP;
		my $obj = eval { JSON::PP->new->decode($out) };
		ok( !$@, 'output parses as JSON' ) or diag("error: $@");
		if ($obj) {
			is( $obj->{type},    'online', 'JSON type matches' );
			is( $obj->{n_trees}, 20,       'JSON n_trees matches' );
		}
	}; ## end 'info --json on an online model parses' => sub

	subtest 'stream refuses a batch model' => sub {
		my $out = `$^X -Ilib $bin stream -i $stream_csv -m $model 2>&1`;
		isnt( $?, 0, 'stream exits non-zero on a batch model' );
		like( $out, qr/not an online model/, 'error explains the mismatch' );
	};
}

# --- munger workflow: fit/predict/stream with raw (non-numeric) CSV -----
SKIP: {
	skip 'Algorithm::ToNumberMunger is not installed', 5
		unless eval { require Algorithm::ToNumberMunger; 1 };

	my $munger_json = "$tmp/mungers.json";
	my $raw_csv     = "$tmp/raw.csv";
	my $mmodel      = "$tmp/munged_model.json";
	my $momodel     = "$tmp/munged_online_model.json";

	{
		open my $fh, '>', $munger_json or die $!;
		print $fh '{ "method": { "munger": "http_method_enum", "default": -1 },'
			. ' "path_len": { "munger": "length", "from": "path" } }';
		close $fh;
	}
	{
		open my $fh, '>', $raw_csv or die $!;
		srand(4);
		my @methods = qw(GET GET GET POST HEAD);
		for my $i ( 1 .. 80 ) {
			printf $fh "%s,%s,%.4f\n", $methods[ $i % 5 ], '/' . ( 'p' x ( 3 + $i % 20 ) ), 500 + rand(400);
		}
		print $fh 'BREW,/' . ( 'a' x 90 ) . ",60000\n";
		close $fh;
	}

	subtest 'fit --mungers accepts raw CSV and saves the spec' => sub {
		my $out
			= `$^X -Ilib $bin fit -i $raw_csv -o $mmodel -n 30 -m 32 -s 42 -t method -t path_len -t bytes --mungers $munger_json 2>&1`;
		is( $?, 0, 'fit --mungers exits 0' ) or diag $out;
		ok( -s $mmodel, 'model written' );
		like(
			scalar(
				do { local ( @ARGV, $/ ) = ($mmodel); <> }
			),
			qr/"mungers"/,
			'model JSON carries the munger spec'
		);
	}; ## end 'fit --mungers accepts raw CSV and saves the spec' => sub

	subtest 'fit --mungers without -t is refused' => sub {
		my $out = `$^X -Ilib $bin fit -i $raw_csv -o $tmp/nope.json -w --mungers $munger_json 2>&1`;
		isnt( $?, 0, 'exits non-zero' );
		like( $out, qr/requires feature tags/, 'error explains -t is needed' );
	};

	subtest 'predict munges raw CSV against a munger-bearing model' => sub {
		my $out = `$^X -Ilib $bin predict -m $mmodel -i $raw_csv 2>&1`;
		is( $?, 0, 'predict exits 0' ) or diag $out;
		my @lines = split /\n/, $out;
		is( scalar @lines, 81, 'one output row per input row' );
		like( $lines[-1], qr/^[\d.eE+-]+,[01]$/, 'rows match "score,label"' );
	};

	subtest 'info shows the munger summary' => sub {
		my $out = `$^X -Ilib $bin info -m $mmodel 2>&1`;
		is( $?, 0, 'info exits 0' );
		like( $out, qr/mungers\s+2 configured/,        'munger count shown' );
		like( $out, qr/method\s+http_method_enum/,     'munger names listed' );
		like( $out, qr/munger_module_version\s+[\d.]/, 'module version shown' );
	};

	subtest 'stream --mungers creates and resumes a munged online model' => sub {
		my $out
			= `$^X -Ilib $bin stream -i $raw_csv -m $momodel -n 20 --window 64 --eta 16 -s 7 -t method -t path_len -t bytes --mungers $munger_json 2>&1`;
		is( $?, 0, 'stream --mungers exits 0' ) or diag $out;
		ok( -s $momodel, 'online model written' );

		# Resume: the model carries its spec, so raw CSV still works with
		# no --mungers flag.
		my $out2 = `$^X -Ilib $bin stream -i $raw_csv -m $momodel 2>&1`;
		is( $?, 0, 'resumed stream exits 0' ) or diag $out2;
		my @lines = split /\n/, $out2;
		is( scalar @lines, 81, 'one output row per input row on resume' );
	}; ## end 'stream --mungers creates and resumes a munged online model' => sub
} ## end SKIP:

# --- prototype workflow: fit/stream --prototype, proto command ----------
{
	my $bproto  = "$tmp/proto_batch.json";
	my $oproto  = "$tmp/proto_online.json";
	my $pmodel  = "$tmp/proto_model.json";
	my $pomodel = "$tmp/proto_online_model.json";

	{
		open my $fh, '>', $bproto or die $!;
		print $fh '{ "format": "Algorithm::Classifier::IsolationForest::Prototype",'
			. ' "class": "batch", "schema_version": "2026.07.08-1",'
			. ' "schema_description": "three synthetic metrics",'
			. ' "schema": { "feature_names": ["cpu", "mem", "disk"],'
			. ' "feature_descriptions": { "cpu": "cpu utilisation fraction" } },'
			. ' "params": { "n_trees": 30, "sample_size": 16 } }';
		close $fh;
	}



( run in 0.766 second using v1.01-cache-2.11-cpan-a9496e3eb41 )