Algorithm-Classifier-IsolationForest
view release on metacpan or search on metacpan
lib/Algorithm/Classifier/IsolationForest/App/Command/pack.pm view on Meta::CPAN
package Algorithm::Classifier::IsolationForest::App::Command::pack;
use strict;
use warnings;
use Algorithm::Classifier::IsolationForest ();
use Algorithm::Classifier::IsolationForest::App -command;
use File::Slurp qw(read_file write_file);
use Scalar::Util qw(looks_like_number);
# .iforest-packed v1 file layout (all little-endian):
#
# offset size field
# -----------------------------------------------------------
# 0 8 magic -- ASCII "IFPKD\0\0\0"
# 8 2 version (uint16, currently 1)
# 10 2 reserved (uint16, must be 0)
# 12 4 n_pts (uint32)
# 16 4 n_feats (uint32)
# 20 ... n_pts * n_feats packed doubles ('d' pack format,
# little-endian per the IEEE-754 native layout)
#
# The format is intentionally minimal: the goal is to skip the CSV
# parse + pack_input_xs cost on subsequent scoring runs. Models are
# not embedded -- the caller must pair the .iforest-packed file with a
# model that has the same n_features at score time.
use constant MAGIC => 'IFPKD' . "\0\0\0"; # 8 bytes
use constant VERSION => 1;
use constant HEADER_LEN => 20;
# Read a .iforest-packed file back into the pieces a scoring command
# needs. Every field in the header above is checked, so a truncated or
# foreign file is reported by name rather than silently scored as noise.
#
# Args:
# $path :: path to the .iforest-packed file, which must be readable.
#
# Returns: the three-element list ($n_pts, $n_feats, $bytes) -- the row
# count, the feature width, and the raw row-major double buffer as a
# string. Dies with a message naming $path on a bad magic, an unsupported
# version, a non-zero reserved field, or a short read.
#
# Example:
# my ( $n_pts, $n_feats, $bytes ) = _read_packed('data.iforest-packed');
# length($bytes) == $n_pts * $n_feats * 8; # true
sub _read_packed {
my ($path) = @_;
open my $fh, '<:raw', $path or die "open '$path' for read: $!\n";
my $hdr;
read( $fh, $hdr, HEADER_LEN ) == HEADER_LEN
or die "'$path' is shorter than a packed-file header\n";
my ( $magic, $version, $reserved, $n_pts, $n_feats ) = unpack( 'a8 v v V V', $hdr );
die "'$path' does not look like a .iforest-packed file\n"
unless $magic eq MAGIC;
die "'$path' is .iforest-packed version $version; only " . VERSION . " is supported\n"
unless $version == VERSION;
die "'$path' has non-zero reserved field $reserved\n"
unless $reserved == 0;
my $bytes;
my $want = $n_pts * $n_feats * 8;
read( $fh, $bytes, $want ) == $want
or die "'$path' truncated: wanted $want bytes, got " . ( defined $bytes ? length($bytes) : 0 ) . "\n";
close $fh;
return ( $n_pts, $n_feats, $bytes );
} ## end sub _read_packed
# Write a .iforest-packed file: the header above followed by the buffer.
# The write is atomic, so a consumer never sees a half-written file even
# if the pack is interrupted.
#
# Args:
# $path :: where to write. An existing file is replaced.
# $n_pts :: the row count, recorded in the header.
# $n_feats :: the feature width, recorded in the header.
# $bytes :: the row-major double buffer as a string, normally straight
# from pack_data. Must be $n_pts * $n_feats * 8 bytes long --
# nothing re-checks that here.
#
# Returns: nothing. Dies through File::Slurp if the write fails.
#
# Example:
# _write_packed( 'data.iforest-packed', $n_pts, $n_feats, $packed->{packed} );
sub _write_packed {
my ( $path, $n_pts, $n_feats, $bytes ) = @_;
my $hdr = pack( 'a8 v v V V', MAGIC, VERSION, 0, $n_pts, $n_feats );
write_file( $path, { 'atomic' => 1, 'binmode' => ':raw' }, $hdr . $bytes );
}
# The public face of _read_packed; see the POD below. Exists so predict,
# explain and bench all read the format the same way rather than each
# reaching for the underscore name.
sub read_packed_file { _read_packed(@_) }
# Cheap, allocation-light magic check; see the POD below. This is how -i
# decides between a CSV and a packed file without being told which it was
# handed.
sub is_packed_file {
my ($path) = @_;
open my $fh, '<:raw', $path or return 0;
my $magic;
my $ok = read( $fh, $magic, 8 ) == 8;
close $fh;
return $ok && $magic eq MAGIC;
}
sub opt_spec {
return (
[
'm=s',
'Model JSON to validate n_features against.',
{ 'default' => 'iforest_model.json', 'completion' => 'files' }
],
[ 'i=s', 'Input CSV to pack.', { 'completion' => 'files' } ],
[ 'o=s', 'Output .iforest-packed file path.', { 'completion' => 'files' } ],
[ 'w', 'Overwrite -o if it already exists.' ],
);
} ## end sub opt_spec
sub abstract { 'Pre-pack a CSV dataset into a binary file the scoring commands can read directly' }
sub description {
'Reads a CSV, validates that every row has the same numeric
( run in 1.402 second using v1.01-cache-2.11-cpan-54e63673c56 )