view release on metacpan or search on metacpan
benchmarking/bench-fit-parallel.pl view on Meta::CPAN
#!/usr/bin/perl
# benchmarking/bench-fit-parallel.pl
#
# Measures fit() throughput across a sweep of parallel_fit worker
# counts. The training data is the same for every run; only the
# `parallel_fit` constructor option changes.
#
# parallel_fit forks workers and divvies up n_trees across them, with
# each worker fitting its share in a child process (via the C tree
# builder when Inline::C is available, same as a non-parallel fit) and
# returning the trees to the parent via Storable on a pipe. Speedup is
# bounded by:
# * core count
# * Storable freeze/thaw + pipe IPC overhead per worker
# * fork() cost (small but non-zero)
#
# With the C builder, a single-process fit is often fast enough that
# this fixed per-worker overhead costs more than splitting the work
# saves -- watch for parallel_fit making things *slower* on small/
# medium datasets in the sweep below.
#
benchmarking/bench-online-score-accel.pl view on Meta::CPAN
#!/usr/bin/perl
# benchmarking/bench-online-score-accel.pl
#
# Benchmarks Online Isolation Forest batch scoring under each acceleration
# backend:
# pure_perl -- use_c => 0 (pure Perl tree walk)
# c_serial -- use_c => 1, use_openmp => 0 (C tree walk, single thread)
# c_openmp -- use_c => 1, use_openmp => 1 (C tree walk, OpenMP parallel)
#
# The online class scores through the parent's C backend by lazily packing
# its mutable trees into the parent's node layout; learning invalidates the
# packed snapshot and the next scoring call repacks once. Learning itself
# (and the per-row walks inside score_learn) runs in C directly against the
# live trees. Sections 1 and 2 measure steady-state batch scoring (snapshot
# reused across calls); section 3 interleaves a learned point before every
# scoring call, so each call pays the repack -- the worst case for the C
# path; sections 4 and 5 measure the prequential score_learn /
# score_learn_tagged stream loop, where the mutable-tree C walks are what
# matters.
#
# Reference numbers (2026-07-08, 8-core dev box, 100 trees, window 2048,
lib/Algorithm/Classifier/IsolationForest.pm view on Meta::CPAN
* the scorer walks. _rebuild_c_trees runs it once per tree at the end of
* every fit() and every from_json(), and in Perl each node costs a
* recursive closure call, an arrayref and six SVs, plus a trailing map
* that pushes every one of those SVs back onto the stack for pack() --
* which made it the largest single phase of an axis-mode fit. This is
* the same walk done in C, appending into the TreeBuf above and handing
* the three buffers back as plain strings.
*
* The layout is unchanged: nodes are numbered DFS pre-order (a node's
* record is reserved before its children recurse, so the root is 0 and
* every child index sits above its parent's -- unlike the post-order
* _build_node_packed above), oblique coefficients dense-pack in feature
* order when a node uses every feature, and a leaf's slot 2 carries
* c(size). Output is byte-identical to the Perl path.
*
* The Perl side only calls this on nvsize == 8 perls: c(size) is computed
* here in C doubles, and a wide-NV perl's _c() keeps extra low bits, so
* the stored leaf adjustment would otherwise differ in the last ulp
* between backends -- the same parity concern _NV_IS_DOUBLE guards
* everywhere else.
* ------------------------------------------------------------------ */
lib/Algorithm/Classifier/IsolationForest.pm view on Meta::CPAN
here, not a forest-level score cutoff.
score_samples() returns the fraction of trees
voting anomalous -- still in [0, 1], but discrete
in steps of 1/n_trees. contamination composes: fit()
learns the per-tree cutoff that flags the requested
fraction of the training set.
default :: mean
- parallel_fit :: positive integer N => build the trees across N forked
worker processes during fit(). Each worker gets a derived seed
(parent seed + worker_id * 1009) so the parallel fit is
reproducible across runs at fixed worker count -- but the trees
produced are NOT bit-identical to a serial fit with the same
seed, because the RNG draws happen in a different order.
Inference is unaffected. Falls back silently to serial on
platforms without a real fork() (e.g. Windows without Cygwin).
default :: undef (serial)
- use_c :: boolean, override whether the Inline::C backend is used for
both scoring and fit()'s tree builder. When false the instance
falls back to pure Perl for both even if the C backend compiled
lib/Algorithm/Classifier/IsolationForest.pm view on Meta::CPAN
(one tree per thread) instead of the single-threaded C builder.
Opt-in and off by default: unlike use_c/use_openmp, this changes
which trees get built. Perl's RNG isn't safe to call from
multiple OS threads sharing one interpreter, so this path seeds
an independent PRNG per tree from the tree index rather than
Drand01() -- trees differ from the use_c (single-threaded)
and pure-Perl paths even with the same seed, though a fixed
seed and n_trees still reproduce the same trees regardless of
OMP_NUM_THREADS or scheduling. Does NOT compose with
parallel_fit: a forked child starting its own OpenMP region
after the parent process has used OpenMP for anything can
hang (a general fork()+libgomp limitation), so parallel_fit's
workers always use the single-threaded C builder regardless
of this setting -- setting both just means parallel_fit wins.
Ignored (clamped to 0) when use_c is false or OpenMP isn't
linked in.
default :: 0
- feature_names :: optional arrayref of per-feature labels enabling the
*_tagged methods (and required by mungers below).
default :: undef
lib/Algorithm/Classifier/IsolationForest.pm view on Meta::CPAN
# idx_packed: int32 feature indices for every oblique-node coefficient
# val_packed: double values matching idx_packed one-for-one
#
# Storing idx and val in separate buffers (SoA) instead of interleaved
# doubles lets the oblique dot product's SIMD inner loop run over a
# contiguous val[] stream without a per-iteration (int) cast, and
# halves the index bandwidth (int32 vs double). The same `coff`
# offset addresses paired entries in both buffers.
#
# Nodes are numbered in DFS pre-order: the root is always index 0 and
# children always get indices larger than their parent's.
#
# The C backend does this walk in pack_tree_xs, which is what actually
# runs whenever it is available -- the Perl body below is the fallback
# (no C backend, or a wide-NV perl, where the two would disagree on
# c(size) in the last ulp; see _NV_IS_DOUBLE). Both produce byte-
# identical buffers on an nvsize == 8 perl.
#
# Args:
# $root :: the tree's root node, in the nested arrayref layout
# _build_tree produces.
lib/Algorithm/Classifier/IsolationForest.pm view on Meta::CPAN
return $cached;
}
}
#-------------------------------------------------------------------------------
# Fork-based parallel tree builder. Used by fit() when parallel_fit > 1
# and the platform has a real fork(). Divides n_trees evenly among
# workers; each child seeds its own RNG ($seed + worker_id * 1009 so
# fixed-worker-count runs are reproducible), builds its share (via the
# C builder when _use_c is on, same as the non-parallel path), and
# returns the trees to the parent via Storable on a one-shot pipe.
#
# The trees that come back differ from a serial fit with the same seed
# because the RNG draws happen in a different order -- this is documented
# as part of the parallel_fit contract.
#
# Args:
# $data :: the prepared training set, an arrayref of feature-value
# arrayrefs. Each worker inherits it through the fork, so it is
# never serialised.
# $psi :: the per-tree sub-sample size from _resolve_geometry.
lib/Algorithm/Classifier/IsolationForest.pm view on Meta::CPAN
croak "fork failed: $!" unless defined $pid;
if ( $pid == 0 ) {
# child
close $rh;
binmode $wh;
if ( defined $self->{seed} ) {
srand( $self->{seed} + $w * 1009 );
}
# Deliberately never _build_forest_openmp here, even when
# use_openmp_fit is on: if this process (or the parent that
# fork()ed us) already ran any OpenMP region before this
# fork -- including plain score_samples()/predict() with
# the default use_openmp -- libgomp's thread pool exists
# but its worker threads didn't survive the fork. A child
# starting its own #pragma omp parallel region then tries
# to reuse that now-invalid pool and hangs. This is a
# general fork()+libgomp limitation, not fixable from here,
# so forked workers always use the single-threaded C
# builder (or pure Perl) instead. See t/03-fit-determinism.t
# and the NATIVE ACCELERATION docs for the observed hang and
lib/Algorithm/Classifier/IsolationForest.pm view on Meta::CPAN
} else {
my @t;
for ( 1 .. $share ) {
my $sample = _subsample( $data, $psi );
push @t, $self->_build_tree( $sample, 0, $limit );
}
$trees = \@t;
}
print $wh Storable::freeze($trees);
close $wh;
# _exit so we don't run parent END/DESTROY in the child.
POSIX::_exit(0);
} ## end if ( $pid == 0 )
close $wh;
binmode $rh;
push @procs, { pid => $pid, rh => $rh, share => $share };
} ## end for my $w ( 0 .. $workers - 1 )
# Collect from each pipe in worker order so the canonical tree
# ordering is deterministic (worker 0's trees first, then 1's, ...).
lib/Algorithm/Classifier/IsolationForest.pm view on Meta::CPAN
# random draws come from a thread-private PRNG seeded per tree index
# rather than Drand01() -- Perl's RNG state can't be shared safely
# across OpenMP threads -- so the resulting trees are NOT bit-identical
# to the use_c (serial) or pure-Perl paths for the same seed, though a
# fixed seed + n_trees still reproduce the same trees regardless of
# OMP_NUM_THREADS. This is why it's gated by the separate, opt-in
# use_openmp_fit knob rather than reusing use_c/use_openmp.
#
# Only called from fit()'s non-forked branch. _fit_trees_parallel's
# workers never call this, even when use_openmp_fit is on: a forked
# child starting its own OpenMP region after the parent process has
# used OpenMP for anything (this includes plain score_samples()) can
# hang -- see the comment above that branch for the fork()+libgomp
# hazard this avoids.
#
# build_forest_openmp_xs hands back three arrayrefs of per-tree packed
# buffers (the same SoA layout _pack_tree produces) instead of Perl tree
# structures -- that's how it avoids any Perl API call inside its
# parallel region. _unpack_forest converts them back into the ordinary
# nested-arrayref tree shape so to_json/from_json/_rebuild_c_trees don't
# need to know this path exists.
lib/Algorithm/Classifier/IsolationForest.pm view on Meta::CPAN
return _unpack_forest( \@nodes, \@idx, \@val );
} ## end sub _build_forest_openmp
# Inverse of _pack_tree's SoA layout: given one tree's packed node
# buffer plus the shared idx/val coefficient buffers, reconstructs the
# ordinary nested-arrayref tree structure _build_tree/_build_node_c
# produce. li/ri fields hold the child's absolute node index, so this
# just follows them recursively from whatever index the caller says the
# root lives at. NOTE: _pack_tree numbers nodes DFS pre-order (root at
# 0), but build_forest_openmp_xs appends nodes post-order (children
# before parent), putting the root LAST -- the caller must pass the
# right root index for the buffer's origin.
#
# Args:
# $nodes :: arrayref of the tree's node doubles, already unpacked, 6 per
# node.
# $idx :: arrayref of the unpacked int32 coefficient feature indices.
# $val :: arrayref of the unpacked coefficient values, matching $idx
# one-for-one.
# $node_i :: which node record to treat as this subtree's root, counting
# in nodes not doubles.
lib/Algorithm/Classifier/IsolationForest/App/Command/streamd.pm view on Meta::CPAN
. $flag
. ' somewhere writable'
. "\n" )
if !-d $dir;
} ## end if ( !-d $dir )
die( '"' . $dir . '" (needed for ' . $flag . ') is not writable; fix permissions or override ' . $flag . "\n" )
unless -w $dir;
return 1;
} ## end sub _ensure_dir
# Classic double-fork daemonization. The parents leave via POSIX::_exit
# so no END blocks (Inline's, App::Cmd's) run twice. The second fork is
# what guarantees the daemon can never reacquire a controlling terminal.
#
# Args: none. Reads nothing and takes nothing -- the caller decides
# whether to daemonize at all (-f keeps the process in the foreground).
#
# Returns: 1, in the grandchild only. The two parents never return: they
# _exit(0) immediately, so the caller either continues as the daemon or
# does not continue. Dies on a failed fork, setsid, chdir or STDIN
# reopen.
#
# Example:
# _daemonize() unless $OPT{'f'};
# # from here on we are the daemon
sub _daemonize {
defined( my $pid = fork() ) or die( 'fork failed: ' . $! . "\n" );
POSIX::_exit(0) if $pid;
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
package Algorithm::Classifier::IsolationForest::Online;
use strict;
use warnings;
use Carp qw(croak);
use JSON::PP ();
use File::Slurp qw(read_file write_file);
# Runtime-only dependency: tagged_row_to_array is delegated to the parent
# class (identical semantics, no point duplicating it) and the
# contamination threshold selection reuses _threshold_from_ranked. The
# parent never loads this module at compile time (its from_json requires
# it on demand), so there is no cycle.
use Algorithm::Classifier::IsolationForest ();
our $VERSION = '0.6.0';
# Node layout. Unlike the batch forest's nodes, online nodes are mutable
# and carry a running point count plus the bounding box (per-feature
# lo/hi) of every point that has passed through them -- that box is what
# split simulation samples from, since points themselves are never stored
# in the tree. Both node types share the first four slots so the
# learn/unlearn bookkeeping never has to branch on type:
#
# leaf: [0, count, \@lo, \@hi]
# internal: [1, count, \@lo, \@hi, attr, split, left, right]
#
# The type tag mirrors the parent's convention (0 is falsy, so
# while ($node->[0]) walks to a leaf). A leaf built from an empty
# synthetic partition has count 0 and an undef box (slots 2/3); the box
# is initialised from the first real point that reaches it.
use constant _N_TYPE => 0;
use constant _N_COUNT => 1;
use constant _N_LO => 2;
use constant _N_HI => 3;
use constant _N_ATTR => 4;
use constant _N_SPLIT => 5;
use constant _N_LEFT => 6;
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
use constant _NT_LEAF => 0;
use constant _NT_AXIS => 1;
# Trees are binary (the reference implementation's branching_factor == 2),
# which fixes the depth-budget log base at log(2 * 2). Spelled as the
# exact-double literal rather than log(4) so it is bit-identical to the
# OL_LOG4 literal the C learn path uses regardless of the platform's
# libm rounding -- a one-ulp disagreement would flip `depth < limit`
# split decisions exactly when a tree's count is eta * 4**k (the same
# TWO_PI trick the parent uses for _randn parity).
use constant _LOG4 => unpack( 'd', pack 'd', 1.3862943611198906 );
use constant _LOG2 => log(2);
# DBL_EPSILON, added to the normalisation factor before dividing so a
# just-started model (normaliser 0) yields well-defined scores instead of
# a division by zero -- the same guard the reference implementation uses.
use constant _EPS => 2.220446049250313e-16;
# The online learn/unlearn/score-row XS functions were added to the C
# backend after the batch-scoring ones, so a prebuilt object installed
# from an older release can back $HAS_C while lacking them (the parent
# trusts a flag-matched prebuilt object without inspecting its symbol
# set). Probe once at load: without them, use_c still accelerates the
# packed-snapshot batch scoring -- those functions have been in the
# object all along -- and learning quietly stays pure Perl instead of
# crashing on an undefined XS sub. Rebuilding/reinstalling (or
# IF_RUNTIME_BUILD=1) restores the full set.
use constant _HAS_ONLINE_XS => defined &Algorithm::Classifier::IsolationForest::online_learn_row_xs ? 1 : 0;
=head1 NAME
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
the split simulates them by sampling uniformly inside the leaf's bounding
box. Forgetting reverses the process: counts are decremented along the
forgotten point's path and a subtree whose count falls below its split
requirement is collapsed back into a leaf.
Scoring follows the classic Isolation Forest intuition -- anomalies
isolate at shallow depth -- but normalises by the depth budget
C<log(n/max_leaf_samples) / log(4)> of the current window rather than the
batch model's C<c(psi)>. Scores are in (0, 1] with high values
anomalous, directly comparable in spirit (though not numerically) to the
parent class's scores.
Both learning and scoring are accelerated through the parent class's
Inline::C backend when it is available; C<use_c> covers them together.
Learning (and the per-row walks inside C<score_learn>) runs in C
directly against the live trees, drawing randomness through the same
generator in the same order as the pure-Perl path -- so, like the
parent's C<fit()>, a C<learn()> with a given seed produces bit-identical
trees whether C<use_c> is on or off (on C<nvsize == 8> perls; wide-NV
perls keep extra low bits in the pure-Perl path). The knob changes
speed, never results.
Batch scoring lazily flattens the mutable trees into the same packed
node layout the batch scorer walks -- online trees are axis-only, and
the online per-leaf depth adjustment rides in the slot the batch packer
uses for its own leaf adjustment -- so C<score_samples>, C<predict>,
C<path_lengths>, C<score_predict_samples>, and C<score_predict_split>
all run through the same C (and OpenMP, when linked) tree walk the
parent uses, with identical results to the pure-Perl fallback. Any
C<learn> invalidates the packed snapshot; the next batch-scoring call
repacks once. C<score_learn> never touches the snapshot: it mutates
the trees after every single point, so its rows are scored by walking
the live trees in C instead.
A model needs to have seen at least C<max_leaf_samples> points before
tree structure exists at all; until then every point scores 1.0. Give
the model a warm-up C<learn()> pass before trusting scores or labels.
Models saved by this class carry their own C<format> tag.
C<< Algorithm::Classifier::IsolationForest->load >> recognises it and
dispatches here, so callers can load either model type through the
parent class.
=head1 GENERAL METHODS
=head2 new(%args)
Inits the object.
- n_trees :: number of isolation trees in the ensemble
default :: 100
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
- contamination :: expected fraction of anomalies, in (0, 0.5]. When
set, the first predict()-family call learns a score threshold
that flags this fraction of the current window, and uses it as
the default cutoff. The threshold does NOT track the stream
automatically afterwards; call relearn_threshold() to refresh
it. undef => no learned threshold (predict() falls back to
0.5).
default :: undef
- missing :: how learn() treats undef (missing) feature cells. Scoring
always tolerates undef (mapped to 0), matching the parent
class's long-standing behaviour.
die :: croak if a learned point contains an undef cell
zero :: treat a missing cell as the value 0
default :: die
- feature_names :: optional arrayref of per-feature labels enabling the
*_tagged methods (and required by mungers below).
default :: undef
- mungers :: optional hashref of declarative L<Algorithm::ToNumberMunger>
specs; every tagged row (learn_tagged, score_learn_tagged, the
scoring *_tagged methods, tagged_row_to_array) is munged from
raw values into numbers through the compiled plan, and
munge_rows() applies the scalar mungers to positional rows.
Requires feature_names; spec errors croak here. The spec is
saved with the model. Identical semantics to the parent
class's knob -- see MUNGERS in
L<Algorithm::Classifier::IsolationForest> for details and
caveats.
default :: undef
- schema_version :: optional opaque string identifying the revision of
the variable schema this model was built against. Never
parsed; saved with the model. Usually set via a prototype --
see PROTOTYPES in L<Algorithm::Classifier::IsolationForest>
(whose new_from_prototype creates online models too).
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
- schema_description :: optional opaque free-text description of what
the variable schema is. Same handling as schema_version.
default :: undef
- feature_descriptions :: optional hashref of 'feature name => free
text' describing individual features. Requires feature_names;
every key must name an entry there or new() croaks. Partial
coverage is fine. Saved with the model.
default :: undef
- use_c :: boolean, override whether the parent class's Inline::C
backend is used, for learning and scoring both (see
DESCRIPTION). When false the instance runs pure Perl even if
the C backend compiled. Results are identical either way --
learn() builds bit-identical trees for the same seed (on
nvsize == 8 perls) and scoring matches exactly -- so only
speed differs.
default :: $Algorithm::Classifier::IsolationForest::HAS_C
- use_openmp :: boolean, override whether OpenMP parallel scoring is
used inside the C tree walk. Ignored when use_c is false.
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
unless $missing =~ /\A(?:die|zero)\z/;
if ( defined( $args{seed} ) ) {
$args{seed} = abs( int( $args{seed} ) );
}
# window_size => 0 and window_size => undef both mean "no forgetting";
# normalise to 0 so the rest of the code has one falsy spelling.
my $window_size = exists $args{window_size} ? ( $args{window_size} // 0 ) : 2048;
# Clamp the accel knobs against what the parent's build actually has,
# exactly as the parent's new() does: use_c => 1 without a compiled
# backend would otherwise call undefined XS subs at first scoring, and
# OpenMP is meaningless without the C tree walk.
my $use_c
= defined $args{use_c}
? ( $args{use_c} && $Algorithm::Classifier::IsolationForest::HAS_C ? 1 : 0 )
: $Algorithm::Classifier::IsolationForest::HAS_C;
my $use_openmp
= defined $args{use_openmp}
? ( $args{use_openmp} && $Algorithm::Classifier::IsolationForest::HAS_OPENMP ? 1 : 0 )
: $Algorithm::Classifier::IsolationForest::HAS_OPENMP;
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
seed => $args{seed},
contamination => $args{contamination},
missing => $missing,
feature_names => $args{feature_names},
threshold => undef, # learned lazily if contamination set
n_features => undef, # learned from the first row
seen => 0, # total points learned over the model's lifetime
window => [], # the retained rows, oldest first
trees => [],
mungers => undef, # optional Algorithm::ToNumberMunger spec hash
# Opaque schema metadata, usually set via the parent class's
# new_from_prototype and persisted with the model.
schema_version => $args{schema_version},
schema_description => $args{schema_description},
feature_descriptions => $args{feature_descriptions},
_use_c => $use_c,
_use_openmp => $use_openmp,
};
for my $doc (qw(schema_version schema_description)) {
croak "$doc must be a plain string"
if defined $self->{$doc} && ref $self->{$doc};
}
Algorithm::Classifier::IsolationForest::_validate_feature_descriptions( $self->{feature_names},
$self->{feature_descriptions} )
if defined $self->{feature_descriptions};
# Optional Algorithm::ToNumberMunger integration, identical to the
# parent's: compiled eagerly so spec errors surface here; the module
# is only required when a spec is actually given.
if ( defined $args{mungers} ) {
croak "mungers must be a hashref of 'tag => munger spec'"
unless ref $args{mungers} eq 'HASH';
croak "mungers requires feature_names (the munger plan compiles against them)"
unless ref $self->{feature_names} eq 'ARRAY' && @{ $self->{feature_names} };
$self->{mungers} = $args{mungers};
$self->{_munger_plan}
= Algorithm::Classifier::IsolationForest::_compile_mungers( $self->{feature_names}, $self->{mungers} );
$self->{munger_module_version} = $Algorithm::ToNumberMunger::VERSION;
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
return bless $self, $class;
} ## end sub new
=head2 learn(\@data)
Learns the passed samples, in order, as the next points of the stream.
Once the model has seen more than C<window_size> points, each learned
point also forgets the oldest retained point, so the model tracks the
most recent C<window_size> points.
The data format matches the parent class's C<fit>: an arrayref of
arrayrefs, each inner arrayref one sample of numeric features. All
samples must have the same feature count; the count is locked in by the
first sample ever learned.
Returns C<$self>, so it chains.
$oif->learn(\@rows);
=cut
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
sub score_sample_tagged {
my ( $self, $row ) = @_;
my $vec = $self->tagged_row_to_array( $row, 'score_sample_tagged' );
my $result = $self->score_samples( [$vec] );
return $result->[0];
}
=head2 explain_samples(\@data, %opts)
Explains, per sample, which features drove its anomaly score, without
learning anything -- the streaming counterpart of the parent class's
method of the same name, returning the identical structure (see
C<explain_samples> in L<Algorithm::Classifier::IsolationForest> for the
full description of the output shape and the C<method> option):
my $explanations = $oif->explain_samples(\@data);
my $top = $explanations->[0]{features}[0];
Differences from the batch class:
The default C<ablation> method substitutes per-feature medians of the
currently retained window (there is no fit() to store baselines at;
the window IS the model's view of normal, and it tracks drift for
free). It therefore requires C<< window_size > 0 >> with learned
points and croaks otherwise -- use C<path> on a windowless model.
The C<path> method carries an extra caveat on top of the batch class's
(see the parent POD): online trees are shallow by construction (the
depth budget is C<log(n/max_leaf_samples)/log(4)>) and most of a
sample's anomalousness lives in the per-leaf count adjustment rather
than in which splits it crossed, so path attributions here are coarse.
Treat them as a rough second opinion; prefer C<ablation> whenever a
window exists.
A model that has not yet accumulated tree structure (fewer than
C<max_leaf_samples> points seen) scores everything 1.0 and has no
splits to attribute; every weight comes back 0.
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
sub explain_sample_tagged {
my ( $self, $row, %opts ) = @_;
my $vec = $self->tagged_row_to_array( $row, 'explain_sample_tagged' );
return $self->explain_samples( [$vec], %opts )->[0];
}
=head2 path_lengths(\@data)
Returns an arrayref of the mean isolation depth per sample across the
trees, for inspection -- the streaming counterpart of the parent class's
method of the same name. Depths include the per-leaf count adjustment.
my $depths = $oif->path_lengths(\@data);
=cut
sub path_lengths {
my ( $self, $data ) = @_;
$self->_check_learned;
croak "path_lengths() expects an arrayref of samples"
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
my ( $self, $data, $threshold ) = @_;
$self->_check_learned;
$self->_ensure_threshold;
$threshold
= defined $threshold ? $threshold
: defined $self->{threshold} ? $self->{threshold}
: 0.5;
# Fast path: threshold the raw depth sums directly, skipping the
# per-point exp() -- score >= T iff sum <= -log(T)/inv. Only valid
# for a normal threshold in (0, 1), like the parent's gate.
if ( $threshold > 0 && $threshold < 1 && $self->_ensure_c_trees ) {
my ( $n_pts, $x_packed ) = $self->_pack_input($data);
my $sums_packed = "\0" x ( $n_pts * 8 );
Algorithm::Classifier::IsolationForest::score_all_xs(
$self->{_c_nodes}, $self->{_c_coef_idx}, $self->{_c_coef_val},
$x_packed, $sums_packed, $n_pts,
$self->{n_features}, scalar @{ $self->{trees} }, $self->{_use_openmp}
);
my $sum_threshold = -log($threshold) / $self->_score_inv;
my $result = [];
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
including points that have since been forgotten.
=cut
sub seen { return $_[0]->{seen} }
=head2 tagged_row_to_array(\%row, $caller)
Validates a hashref of named feature values against the model's stored
C<feature_names> and returns a positional arrayref. Identical semantics
to the parent class's method of the same name (to which it delegates);
see there for the croak conditions.
=cut
sub tagged_row_to_array {
my $self = shift;
return Algorithm::Classifier::IsolationForest::tagged_row_to_array( $self, @_ );
}
=head2 munge_rows(\@rows)
Applies the model's scalar mungers to positional rows, exactly as the
parent class's method of the same name (to which it delegates); a model
without C<mungers> returns the input unchanged.
=cut
sub munge_rows {
my $self = shift;
return Algorithm::Classifier::IsolationForest::munge_rows( $self, @_ );
}
=head1 MODEL SAVE/LOAD METHODS
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
window_size => $p->{window_size} // 0,
max_leaf_samples => $p->{max_leaf_samples},
growth => $p->{growth} // 'adaptive',
subsample => $p->{subsample} // 1.0,
seed => undef,
contamination => $p->{contamination},
threshold => $p->{threshold},
n_features => $p->{n_features},
missing => $p->{missing} // 'die',
feature_names => $p->{feature_names},
# Recompiled lazily on first tagged use, like the parent.
mungers => $p->{mungers},
munger_module_version => $p->{munger_module_version},
# Opaque schema metadata; absent in models saved before prototype
# support, which just means "none recorded".
schema_version => $p->{schema_version},
schema_description => $p->{schema_description},
feature_descriptions => $p->{feature_descriptions},
seen => $p->{seen} // 0,
window => $payload->{window} // [],
trees => [],
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
my ( $class, $path ) = @_;
my $raw_model = read_file($path);
return $class->from_json($raw_model);
}
=head2 to_prototype
Returns a prototype JSON string extracted from this model: its variable
schema (feature_names, feature_descriptions, mungers, missing policy)
plus its current tuning knobs, with C<"class": "online">. Identical
semantics to the parent class's method -- see PROTOTYPES in
L<Algorithm::Classifier::IsolationForest> for the file format and the
croak/placeholder rules. C<seed> is not emitted; pass it as an override
when creating from the prototype.
my $proto_json = $oif->to_prototype;
=cut
sub to_prototype {
my ($self) = @_;
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
=cut
###
###
### internal stuff below
###
###
# Guard for every method that needs a model with something in it. The
# online counterpart of the parent's _check_fitted: there is no fit() here,
# so "usable" means at least one point has been learned.
#
# Args: none beyond the model itself.
#
# Returns: nothing on success. Croaks when the model has seen no points --
# a fresh model that has not been fed anything yet.
#
# Example:
# $self->_check_learned; # croaks with "model has not learned any data yet"
sub _check_learned {
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
# Learning.
#-------------------------------------------------------------------------------
# Advance the stream by one (already prepped) row: every tree learns it
# (subject to subsampling), it enters the window, and the oldest point
# beyond the window is forgotten. This is the single choke point through
# which every tree mutation flows, so it is also where the packed C
# scoring snapshot gets invalidated.
#
# With use_c the per-tree learn and eviction loops run inside the
# parent's C backend (online_learn_row_xs / online_unlearn_row_xs),
# mutating the same live trees this file's Perl recursion would. Random
# draws go through the same generator in the same order, so the trees
# built are bit-identical either way (on nvsize == 8 perls) -- use_c
# only changes speed, matching fit()'s guarantee.
#
# Args:
# $r :: one sample, already through _prep_row -- dense, width-checked,
# and owned by the model, since the window keeps this very
# reference rather than a copy.
#
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
} else {
$tree->{root} = $self->_node_learn( $tree->{root}, $x, 0, $tree->{depth_limit} );
}
return;
} ## end sub _tree_learn
# Route $x down to its leaf, growing counts and bounding boxes along the
# path. A leaf that has accumulated its split requirement (and still has
# depth budget) is replaced by a subtree built from synthetic points
# sampled inside its box -- the return value replaces the node in the
# parent, which is how leaves turn into subtrees in place.
#
# Args:
# $node :: the node the point has reached, in the layout at the top of
# this file. Mutated in place.
# $x :: one prepped sample, an arrayref of feature values.
# $depth :: this node's depth, 0 at the root.
# $limit :: the tree's current depth budget, from _rpl of its count.
#
# Returns: the node that should occupy this slot afterwards -- usually
# $node itself, but a freshly built subtree when a leaf just split. The
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
my $r = _collapse( $node->[_N_RIGHT] );
my ( $lo, $hi ) = _box_union( $l, $r );
if ( !defined $lo ) {
# Both children empty: keep the node's own box.
( $lo, $hi ) = ( $node->[_N_LO], $node->[_N_HI] );
}
return [ _NT_LEAF, $node->[_N_COUNT], $lo, $hi ];
} ## end sub _collapse
# (lo, hi) of the union of two nodes' boxes, as fresh arrays (parent
# boxes grow in place, so they must never alias a child's). Nodes with
# no box yet (empty leaves) are skipped; (undef, undef) if neither has
# one.
#
# Args:
# $a, $b :: two nodes, in the layout at the top of this file. Either may
# be an empty leaf whose box slots are still undef.
#
# Returns: the two-element list ($lo, $hi) of fresh arrayrefs covering both
# nodes, or (undef, undef) when neither has a box yet.
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
}
return ( $lo, $hi );
} ## end sub _box_of
#-------------------------------------------------------------------------------
# Scoring.
#-------------------------------------------------------------------------------
# Depth of the leaf $x lands in, plus the leaf's own depth budget -- the
# streaming analogue of the batch scorer's c(leaf size) adjustment.
# Scoring tolerates undef cells (mapped to 0), matching the parent class.
#
# Args:
# $x :: one sample, an arrayref of feature values. undef cells are
# allowed and count as 0.
# $node :: the node to start walking from, normally a tree's root. Must
# be defined -- callers skip trees that have not grown one.
#
# Returns: the path length as a float: edges walked plus the leaf's
# _rpl(count), so it is rarely a whole number.
#
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
my ( $self, $x, $node ) = @_;
my $depth = 0;
while ( $node->[_N_TYPE] ) {
$node = ( $x->[ $node->[_N_ATTR] ] // 0 ) < $node->[_N_SPLIT] ? $node->[_N_LEFT] : $node->[_N_RIGHT];
$depth++;
}
return $depth + $self->_rpl( $node->[_N_COUNT] );
}
# Per-sample depth sums across all trees (tree-outer, sample-inner for
# cache locality, mirroring the parent's pure-Perl loops).
#
# Args:
# $data :: the samples to walk, an arrayref of feature-value arrayrefs
# already through _prep_row or otherwise known dense-ish (undef
# cells count as 0).
#
# Returns: arrayref of per-sample depth sums, positionally matching $data.
# Trees with no root contribute nothing, so a brand-new model yields all
# zeroes.
#
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
}
my $sum = 0;
for my $tree ( @{ $self->{trees} } ) {
$sum += $self->_depth_of( $r, $tree->{root} ) if defined $tree->{root};
}
return exp( -$sum * $self->_score_inv );
} ## end sub _score_row
#-------------------------------------------------------------------------------
# Explanation (explain_samples) internals. The output shaping is shared
# with the parent class (_credit_to_features / _deltas_to_features) so
# both classes return the identical structure; only the walks and the
# baseline source differ.
#-------------------------------------------------------------------------------
# Path-credit explanation: _depth_of's exact routing recorded as
# (path length, crossed features) walks, turned into local-DIFFI
# credit by the parent's _walks_to_credit (see _path_length_explain
# there for the weighting and its rationale). Path lengths carry the
# per-leaf _rpl(count) adjustment, exactly as scoring's do -- with the
# shallow trees online models build, most of the between-tree contrast
# lives in that adjustment, not the raw depth. Online trees are
# axis-only, so each node credits exactly one feature (share 1).
#
# Args:
# $data :: the samples to explain, an arrayref of feature-value
# arrayrefs, already prepped by the caller.
#
# Returns: arrayref of one hashref per row, in input order, each holding
# score, method (always 'path') and features -- the same sorted structure
# the parent class returns, so callers cannot tell the two apart.
#
# Example:
# my $out = $self->_explain_path( [ [ 8.1, 0.2 ] ] );
# $out->[0]{features}[0]{name}; # the feature most responsible
sub _explain_path {
my ( $self, $data ) = @_;
my $scores = $self->score_samples($data);
my $nf = $self->{n_features};
my $names = $self->{feature_names};
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
method => 'path',
features => Algorithm::Classifier::IsolationForest::_credit_to_features( $names, $credit, $x ),
};
} ## end for my $i ( 0 .. $#$data )
return \@out;
} ## end sub _explain_path
# Counterfactual explanation: every row followed by its n_features
# single-feature baseline substitutions, scored as one batch (which
# rides the packed-snapshot C scorer when available). Mirrors the
# parent's _explain_ablation with the window medians as baselines.
#
# Args:
# $data :: the samples to explain, an arrayref of feature-value
# arrayrefs, already prepped by the caller.
#
# Returns: arrayref of one hashref per row, in input order, each holding
# score, method (always 'ablation') and features. Croaks by way of
# _window_baselines when the model has no retained window to take medians
# from.
#
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
= $k % 2
? $vals[ int( $k / 2 ) ]
: ( $vals[ $k / 2 - 1 ] + $vals[ $k / 2 ] ) / 2.0;
}
return \@baselines;
} ## end sub _window_baselines
#-------------------------------------------------------------------------------
# C-accelerated scoring.
#
# The parent class's Inline::C scorer walks immutable packed node buffers;
# online trees mutate on every learned point. The bridge is a lazily
# built snapshot: the first scoring call after any mutation flattens the
# live trees into the parent's packed node layout (below) and every
# scoring call until the next mutation reuses it. _learn_row -- the one
# choke point all mutations flow through -- drops the snapshot.
#
# Online trees are axis-only, so they map onto the parent's 6-double node
# records directly:
#
# leaf: [0, count, _rpl(count), 0, 0, 0]
# axis: [1, attr, split, li, ri, 0]
#
# The parent packs c(leaf size) into slot 2 and its C walker returns
# depth + slot2 at a leaf; packing the online depth-budget adjustment
# _rpl(count) there instead makes score_all_xs compute exactly the
# pure-Perl _depth_of value, so every downstream C helper (finalize_*,
# predict_sums_xs, score_predict_*) applies unchanged. The per-tree
# coefficient buffers are empty -- there are no oblique nodes -- and only
# exist because score_all_xs expects them.
#
# score_learn deliberately never uses this path: it mutates the trees
# after every single point, so the snapshot could never be reused and
# repacking per point would cost more than the walks it replaces.
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
# Args: none beyond the model itself.
#
# Returns: 1 when the C scoring path may be taken -- the snapshot is
# present and current afterwards -- and 0 when the caller must fall back to
# the pure-Perl walk because use_c is off. Repacking only happens on the
# first call after a mutation; later calls just confirm the snapshot.
#
# Example:
# if ( $self->_ensure_c_trees ) {
# my ( $n_pts, $x ) = $self->_pack_input($data);
# # ... score through the parent's score_all_xs
# }
sub _ensure_c_trees {
my ($self) = @_;
return 0 unless $self->{_use_c};
return 1 if $self->{_c_nodes};
my ( @c_nodes, @c_coef_idx, @c_coef_val );
my $empty_idx = pack('l*');
my $empty_val = pack('d*');
for my $tree ( @{ $self->{trees} } ) {
push @c_nodes, $self->_pack_online_tree( $tree->{root} );
push @c_coef_idx, $empty_idx;
push @c_coef_val, $empty_val;
}
$self->{_c_nodes} = \@c_nodes;
$self->{_c_coef_idx} = \@c_coef_idx;
$self->{_c_coef_val} = \@c_coef_val;
return 1;
} ## end sub _ensure_c_trees
# Flatten one live tree into the parent's packed node buffer (DFS
# pre-order, root at index 0 -- the origin score_all_xs walks from).
#
# Args:
# $root :: the tree's root node, or undef for a tree that has not learned
# anything yet.
#
# Returns: a packed 'd*' string of 6 doubles per node, in the parent's node
# layout. An undef root yields the single zeroed leaf record described
# above, which walks as depth 0 with no adjustment.
#
# Example:
# my $buf = $self->_pack_online_tree( $tree->{root} );
# length($buf) / ( 6 * 8 ); # node count
sub _pack_online_tree {
my ( $self, $root ) = @_;
# A tree that has not learned anything walks as depth 0 with a zero
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
$node_data[$my_idx]
= [ 1.0, $node->[_N_ATTR] + 0.0, $node->[_N_SPLIT] + 0.0, $li + 0.0, $ri + 0.0, 0.0 ];
}
return $my_idx;
}; ## end $assign = sub
$assign->($root);
return pack( 'd*', map { @$_ } @node_data );
} ## end sub _pack_online_tree
# Pack the query rows into the row-major double buffer score_all_xs
# reads, via the parent's C row walker. miss_mode 0 maps an undef cell
# to 0.0, matching the pure-Perl walk's "// 0".
#
# Args:
# $data :: the samples to pack, an arrayref of feature-value arrayrefs.
# Each must be n_features wide.
#
# Returns: the two-element list ($n_pts, $x_packed) -- the row count and
# the row-major 'd*' buffer. There is no online counterpart to the
# parent's PackedData: the snapshot goes stale on every learn, so buffers
# are built per call.
#
# Example:
# my ( $n_pts, $x ) = $self->_pack_input( \@rows );
sub _pack_input {
my ( $self, $data ) = @_;
my $n_pts = scalar @$data;
my $nf = $self->{n_features};
my $x_packed = "\0" x ( $n_pts * $nf * 8 );
Algorithm::Classifier::IsolationForest::pack_input_xs( $data, $x_packed, $n_pts, $nf, 0, '' );
t/03-fit-determinism.t view on Meta::CPAN
# 3. use_openmp_fit is reproducible for a fixed seed + n_trees
# regardless of OMP_NUM_THREADS (checked across separate
# subprocesses, since that's the only way to force a different
# thread count) -- it deliberately does NOT match the Drand01-based
# backends (documented behaviour: it uses its own thread-safe PRNG),
# so that's checked as a "differs, but each is internally consistent"
# property rather than cross-backend equality.
# 4. use_openmp_fit + parallel_fit together is still safe and
# reproducible -- but does NOT actually run forked workers in
# OpenMP. A forked child starting its own OpenMP region after
# the parent process has used OpenMP for anything (including
# plain score_samples()) can hang -- a general fork()+libgomp
# limitation -- so parallel_fit's workers always use the
# single-threaded C builder regardless of use_openmp_fit. This
# was caught by an earlier version of this test hanging.
use strict;
use warnings;
use Test::More;
use File::Temp qw(tempfile);
use Config;
t/35-online-accel.t view on Meta::CPAN
#!perl
# 35-online-accel.t
#
# C-accelerated scoring and learning for the Online Isolation Forest.
# Scoring reuses the parent's Inline::C scorer by lazily packing the
# mutable trees into the parent's node layout; learning runs the
# per-tree insert/forget walks in C against the live trees with the
# same RNG draw order as pure Perl. The contract to test is the same
# one the parent keeps: use_c changes speed, never results.
#
# * every scoring method returns bit-identical values with use_c on
# and off (including undef cells and learned contamination cutoffs)
# * mutating the model (learn, and window eviction/unlearn) drops the
# packed snapshot -- the next C-path call must reflect the new trees
# * OpenMP on/off does not change results
# * a reloaded model (which defaults to use_c) scores identically
# * learning under the same seed and stream builds byte-identical
# models whether use_c is on or off, across every learn code path
# (eviction/collapse, growth modes, subsampling, missing => zero)
#
# Skipped entirely when the parent's C backend did not compile. The
# learn-parity subtests additionally skip when the loaded C object
# predates the online learn accelerators (older prebuilt) or on
# wide-NV perls.
use strict;
use warnings;
use Test::More;
use File::Temp qw(tempdir);
use Config ();
t/35-online-accel.t view on Meta::CPAN
# (scoring maps undef to 0 on both backends).
my @eval = ( [ 0, 0 ], [ 8, 8 ], [ 0.5, -0.3 ], [ undef, 2 ], [ -7, 7 ] );
sub with_knobs {
my ( $m, $use_c, $use_openmp, $code ) = @_;
local $m->{_use_c} = $use_c;
local $m->{_use_openmp} = ( $use_openmp && $Algorithm::Classifier::IsolationForest::HAS_OPENMP ) ? 1 : 0;
return $code->();
}
subtest 'constructor knobs clamp like the parent' => sub {
my $on = $class->new( use_c => 1 );
is( $on->{_use_c}, 1, 'use_c => 1 sticks when the backend compiled' );
my $off = $class->new( use_c => 0 );
is( $off->{_use_c}, 0, 'use_c => 0 forces pure Perl' );
is( $off->{_use_openmp}, 0, 'use_openmp is clamped off without use_c' );
my $omp_only = $class->new( use_c => 0, use_openmp => 1 );
is( $omp_only->{_use_openmp}, 0, 'use_openmp => 1 is still clamped off without use_c' );
}; ## end 'constructor knobs clamp like the parent' => sub
subtest 'scoring parity: C vs pure Perl' => sub {
my $m = make_model();
# Prime the learned contamination threshold on the Perl path so both
# backends label against the identical cutoff.
with_knobs( $m, 0, 0, sub { $m->predict( \@eval ) } );
my %perl = with_knobs(
$m, 0, 0,
t/39-online-stream.t view on Meta::CPAN
#!perl
# 39-online-stream.t
#
# Streaming behaviour of Algorithm::Classifier::IsolationForest::Online:
# drift adaptation through the sliding window (learning AND forgetting),
# subtree collapse bookkeeping, contamination thresholds, unbounded
# (window_size 0) operation, and persistence -- including resuming the
# stream after a save/load round trip and loading through the parent
# class's format dispatch.
use strict;
use warnings;
use Test::More;
use Algorithm::Classifier::IsolationForest ();
use Algorithm::Classifier::IsolationForest::Online ();
use File::Temp qw(tempdir);
t/39-online-stream.t view on Meta::CPAN
# The stream can resume: learning after a reload keeps the window
# bookkeeping intact.
$re->learn( cluster( 100, 0 ) );
is( $re->window_count, 256, 'window still capped after resuming' );
is( $re->seen, 500, 'seen kept counting after resuming' );
for my $tree ( @{ $re->{trees} } ) {
is( $tree->{count}, 256, 'per-tree counts consistent after resuming' );
}
# Parent-class load dispatches on the format tag.
my $via_parent = Algorithm::Classifier::IsolationForest->load($path);
isa_ok( $via_parent, $class, 'parent load() returns an online model' );
# And the online class refuses a batch model.
srand(12);
my $batch = Algorithm::Classifier::IsolationForest->new( n_trees => 5, seed => 1 );
$batch->fit( cluster( 64, 0 ) );
ok( !eval { $class->from_json( $batch->to_json ); 1 }, 'online from_json rejects a batch model' );
ok( !eval { $class->from_json('{"format":"bogus"}'); 1 }, 'online from_json rejects unknown formats' );
}; ## end 'persistence round trip' => sub
done_testing;
t/41-mungers.t view on Meta::CPAN
my $normal = $m->score_sample_tagged( {%NORMAL} );
my $weird = $m->score_learn_tagged( {%WEIRD} );
cmp_ok( $weird, '>', $normal, 'raw anomalous row scores above a normal one prequentially' );
my $path = "$dir/oiforest_munged.json";
$m->save($path);
# Parent-class load dispatches AND keeps the munger spec.
my $re = Algorithm::Classifier::IsolationForest->load($path);
isa_ok( $re, $online_class, 'parent load() returns the online model' );
is_deeply( $re->{mungers}, mungers(), 'munger spec survived the online round trip' );
my $s1 = $m->score_sample_tagged( {%WEIRD} );
my $s2 = $re->score_sample_tagged( {%WEIRD} );
cmp_ok( abs( $s2 - $s1 ), '<', 1e-12, 'reloaded online model munges and scores identically' );
}; ## end 'Online: learn_tagged batches, prequential scoring, persistence' => sub
done_testing;
t/42-prototype.t view on Meta::CPAN
is( $b->{n_trees}, 25, 'params applied' );
is( $b->{sample_size}, 64, 'sample_size applied' );
is( $b->{voting}, 'majority', 'voting applied' );
is( $b->{seed}, 42, 'override applied' );
is( $b->schema_version, 'b1', 'schema_version stamped' );
is( $b->schema_description, 'batch variant', 'schema_description stamped' );
is_deeply( $b->feature_names, [ 'cpu', 'mem' ], 'feature_names from the schema' );
my $o = $online_class->new( n_trees => 1 ); # just to prove dispatch is by prototype, not caller
$o = $batch_class->new_from_prototype( proto_online() );
isa_ok( $o, $online_class, 'online prototype creates the online class through the parent' );
is( $o->{window_size}, 128, 'online params applied' );
is( $o->{max_leaf_samples}, 8, 'eta applied' );
is( $o->{missing}, 'zero', 'schema missing policy applied' );
is_deeply( $o->feature_descriptions, { cpu => 'cpu utilisation fraction' }, 'feature_descriptions applied' );
my $tuned = $batch_class->new_from_prototype( proto_online(), n_trees => 99 );
is( $tuned->{n_trees}, 99, 'override beats the prototype param' );
ok( !eval { $batch_class->new_from_prototype( proto_online(), mungers => {} ); 1 },
'overriding a schema key croaks' );
t/42-prototype.t view on Meta::CPAN
$f->save("$dir/model.json");
my $re = $batch_class->load("$dir/model.json");
is( $re->schema_version, 'b1', 'batch: schema_version survives save/load' );
is( $re->schema_description, 'batch variant', 'batch: schema_description survives save/load' );
srand(12);
my $o = $batch_class->new_from_prototype( proto_online(), seed => 7 );
$o->learn( rows(100) );
$o->save("$dir/omodel.json");
my $ore = Algorithm::Classifier::IsolationForest->load("$dir/omodel.json");
isa_ok( $ore, $online_class, 'parent load() dispatches the online model' );
is( $ore->schema_version, '2026.07.08-1', 'online: schema_version survives save/load' );
is_deeply(
$ore->feature_descriptions,
{ cpu => 'cpu utilisation fraction' },
'online: feature_descriptions survive save/load'
);
}; ## end 'load_prototype and persistence of the schema metadata' => sub
subtest 'to_prototype extraction round trip' => sub {
my $data = do { srand(13); rows(150) };
t/91-streamd.t view on Meta::CPAN
}; ## end 'saves: command, interval, symlink' => sub
my $seen_at_shutdown = rt( $c, { cmd => 'stats' } )->{ok}{seen};
subtest 'clean shutdown and resume' => sub {
is( stop_daemon($daemon), 0, 'SIGTERM exits 0' );
undef $daemon;
ok( !-e $sock, 'socket removed' );
ok( !-e $pidf, 'pid file removed' );
# latest.json is a complete model; the parent class loads it and it
# carries everything learned (the shutdown save flushed the tail).
require Algorithm::Classifier::IsolationForest;
my $m = Algorithm::Classifier::IsolationForest->load($latest);
isa_ok( $m, 'Algorithm::Classifier::IsolationForest::Online', 'latest.json' );
is( $m->seen, $seen_at_shutdown, 'shutdown save captured everything learned' );
# Restart with no creation knobs: resumes from latest.json.
$daemon = start_daemon( '--save-interval' => 60 );
my $c3 = connect_client();
is( rt( $c3, { cmd => 'stats' } )->{ok}{seen}, $seen_at_shutdown, 'restart resumed from latest.json' );