Algorithm-Classifier-IsolationForest
view release on metacpan or search on metacpan
t/90-cli-commands.t view on Meta::CPAN
{
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;
}
{
open my $fh, '>', $oproto or die $!;
print $fh '{ "format": "Algorithm::Classifier::IsolationForest::Prototype",'
. ' "class": "online", "schema_version": "s2",'
. ' "schema_description": "online metrics stream",'
. ' "schema": { "feature_names": ["cpu", "mem", "disk"] },'
. ' "params": { "n_trees": 20, "window_size": 64, "max_leaf_samples": 16 } }';
close $fh;
}
subtest 'fit --prototype creates a model carrying the schema metadata' => sub {
my $out = `$^X -Ilib $bin fit --prototype $bproto -i $train_csv -o $pmodel -s 42 2>&1`;
is( $?, 0, 'fit --prototype exits 0' ) or diag $out;
ok( -s $pmodel, 'model written' );
my $info = `$^X -Ilib $bin info -m $pmodel 2>&1`;
is( $?, 0, 'info exits 0' );
like( $info, qr/schema_version\s+2026\.07\.08-1/, 'schema_version shown, unmangled' );
like( $info, qr/schema_description\s+three synthetic metrics/, 'schema_description shown' );
like( $info, qr/\[0\]\s+cpu -- cpu utilisation fraction/, 'feature description beside its tag' );
like( $info, qr/\[1\]\s+mem\s*$/m, 'undescribed tag rendered bare' );
like( $info, qr/n_trees\s+30/, 'prototype param applied' );
}; ## end 'fit --prototype creates a model carrying the schema metadata' => sub
subtest 'fit --prototype refusals' => sub {
my $out = `$^X -Ilib $bin fit --prototype $bproto -t cpu -i $train_csv -p 2>&1`;
isnt( $?, 0, 'combining --prototype with -t exits non-zero' );
like( $out, qr/may not be combined/, 'error explains the conflict' );
$out = `$^X -Ilib $bin fit --prototype $oproto -i $train_csv -p 2>&1`;
isnt( $?, 0, 'an online prototype is refused by fit' );
like( $out, qr/use `iforest stream`/, 'error points at stream' );
};
subtest 'stream --prototype creates an online model' => sub {
my $out = `$^X -Ilib $bin stream --prototype $oproto -i $train_csv -m $pomodel -s 7 2>&1`;
is( $?, 0, 'stream --prototype exits 0' ) or diag $out;
ok( -s $pomodel, 'online model written' );
my $info = `$^X -Ilib $bin info -m $pomodel 2>&1`;
like( $info, qr/type\s+online/, 'created model is online' );
like( $info, qr/schema_version\s+s2/, 'schema_version carried' );
like( $info, qr/schema_description\s+online metrics stream/, 'schema_description carried' );
like( $info, qr/window_size\s+64/, 'prototype param applied' );
# Resume ignores the creation knob, like the rest of them.
my $out2 = `$^X -Ilib $bin stream --prototype $oproto -i $train_csv -m $pomodel 2>&1`;
is( $?, 0, 'resumed stream with the flag still exits 0' ) or diag $out2;
$out = `$^X -Ilib $bin stream --prototype $bproto -i $train_csv -m $tmp/nope_online.json 2>&1`;
isnt( $?, 0, 'a batch prototype is refused by stream' );
like( $out, qr/use `iforest fit`/, 'error points at fit' );
}; ## end 'stream --prototype creates an online model' => sub
subtest 'proto --from-model extracts a working prototype' => sub {
my $extracted = "$tmp/extracted_proto.json";
my $out = `$^X -Ilib $bin proto --from-model $pmodel -o $extracted 2>&1`;
is( $?, 0, 'proto --from-model exits 0' ) or diag $out;
ok( -s $extracted, 'prototype file written' );
my $check = `$^X -Ilib $bin proto --check $extracted 2>&1`;
is( $?, 0, 'extracted prototype passes --check' ) or diag $check;
like( $check, qr/class\s+batch/, 'summary shows the class' );
like( $check, qr/schema_version\s+2026\.07\.08-1/, 'summary keeps the schema_version' );
like( $check, qr/\[0\]\s+cpu -- cpu utilisation fraction/, 'summary lists feature descriptions' );
# The extraction closes the loop: fit a fresh model from it.
my $refit = `$^X -Ilib $bin fit --prototype $extracted -i $train_csv -o $tmp/refit.json -s 42 2>&1`;
is( $?, 0, 'fit from the extracted prototype exits 0' ) or diag $refit;
}; ## end 'proto --from-model extracts a working prototype' => sub
subtest 'proto --check rejects invalid input' => sub {
my $bad = "$tmp/bad_proto.json";
{
open my $fh, '>', $bad or die $!;
print $fh '{ "format": "Algorithm::Classifier::IsolationForest::Prototype", "class": "batch" }';
close $fh;
}
my $out = `$^X -Ilib $bin proto --check $bad 2>&1`;
isnt( $?, 0, 'invalid prototype exits non-zero' );
like( $out, qr/is not a valid prototype/, 'error says the file is invalid' );
$out = `$^X -Ilib $bin proto --check $bad --from-model $pmodel 2>&1`;
isnt( $?, 0, 'combining --check and --from-model exits non-zero' );
like( $out, qr/exactly one of/, 'error explains the exclusivity' );
$out = `$^X -Ilib $bin proto 2>&1`;
isnt( $?, 0, 'neither switch exits non-zero' );
}; ## end 'proto --check rejects invalid input' => sub
}
# Ensure the module's HAS_C flag was probed before any SKIP block.
BEGIN { require Algorithm::Classifier::IsolationForest; }
done_testing;
( run in 0.957 second using v1.01-cache-2.11-cpan-600a1bdf6e4 )