Algorithm-ToNumberMunger

 view release on metacpan or  search on metacpan

lib/Algorithm/ToNumberMunger.pm  view on Meta::CPAN

Compile a set's C<tags> and (optional) C<mungers> into a B<plan> object that maps
one input record to a fully-numeric row in tag order. Unlike L</build_all> (which
just compiles each spec in isolation), C<compile> understands the whole set:

=over 4

=item * a scalar munger, keyed by its output tag, fills that one column; its
input is read from the tag's own name, or from C<< from => 'other' >> to alias a
source field;

=item * an B<expanding> munger, keyed by any label and carrying C<< into =>
[tag, ...] >>, reads one source (C<from>, defaulting to the label) and fills
several columns at once -- this is how a single timestamp becomes both a
C<sin>/C<cos> pair without the two ever drifting apart (see L</datetime>);

=item * a B<combining> munger, keyed by its output tag and carrying a C<from>
B<list> (C<< from => ['bytes_out', 'bytes_in'] >>), reads several source
fields and fills that one column -- this is how a ratio becomes a single
feature without precomputing it upstream (see L</ratio> and L</combine>). The
sources are raw input fields, not other (possibly munged) columns;

=item * every remaining tag is B<raw> and passed through unchanged.

=back

Coverage is validated up front: C<compile> croaks if two mungers write the same
column, if an C<into> names a column not in C<tags>, if a munger key is neither a
tag nor an expander, if an expander's output count does not match its C<into>,
or if a C<from> list is given to a munger that cannot combine inputs. The
returned plan has two methods, both returning an arrayref of numbers in C<tags>
order: C<apply_named(\%hash)> (keyed by field name, the only form that supports
expanders and combiners) and C<apply_positional(\@row)> (positional; croaks if
the set has any expanding or combining munger, since a shared or combined
source cannot be expressed by position).

=cut

# name => builder returning ($list_returning_code, $arity), for the mungers that
# can fan one input out into several columns via 'into'.
my %MULTI_BUILDERS = (
	datetime => \&_build_datetime_multi,
	eps      => \&_build_eps_multi,
	chain    => \&_build_chain_multi,

lib/Algorithm/ToNumberMunger.pm  view on Meta::CPAN

	croak "compile: 'mungers' must be a hashref"
		unless ref $mungers eq 'HASH';

	my %pos;
	for my $i ( 0 .. $#$tags ) {
		croak "compile: duplicate tag '$tags->[$i]'"
			if exists $pos{ $tags->[$i] };
		$pos{ $tags->[$i] } = $i;
	}

	my ( @scalar, @expand, @combine, %claimed );
	my $claim = sub {
		my ( $tag, $by ) = @_;
		croak "munger '$by' targets unknown column '$tag'"
			unless exists $pos{$tag};
		croak "two mungers write column '$tag'"
			if $claimed{$tag}++;
	};

	for my $key ( sort keys %$mungers ) {
		my $spec = $mungers->{$key};

lib/Algorithm/ToNumberMunger.pm  view on Meta::CPAN

			$claim->( $key, $key );
			push @combine, { tag => $key, from => [@$from], code => $code };
		} elsif ( defined $spec->{into} ) {
			my $into = $spec->{into};
			croak "munger '$key': 'into' must be a non-empty arrayref"
				unless ref $into eq 'ARRAY' && @$into;
			my ( $code, $arity ) = $class->_build_multi( $spec, " for '$key'" );
			croak "munger '$key' produces $arity value(s) but 'into' lists " . scalar(@$into)
				unless $arity == @$into;
			$claim->( $_, $key ) for @$into;
			push @expand, { from => $from, into => [@$into], code => $code };
		} else {
			croak "munger '$key' is not a declared tag and has no 'into'"
				unless exists $pos{$key};
			$claim->( $key, $key );
			push @scalar, { tag => $key, from => $from, code => $class->build( $spec, $key ) };
		}
	} ## end for my $key ( sort keys %$mungers )

	for my $tag (@$tags) {
		push @scalar, { tag => $tag, from => $tag, code => undef }
			unless $claimed{$tag};
	}

	return bless {
		tags    => [@$tags],
		pos     => \%pos,
		scalar  => \@scalar,
		expand  => \@expand,
		combine => \@combine,
		},
		"${class}::Plan";
} ## end sub compile

=head2 known_mungers

    my @names = ...->known_mungers;

The sorted list of built-in munger names this version understands.

lib/Algorithm/ToNumberMunger.pm  view on Meta::CPAN

# testable without a Writer or the filesystem.
package Algorithm::ToNumberMunger::Plan;

use strict;
use warnings;
use Carp qw(croak);

sub tags { return $_[0]->{tags} }

# Assemble a row from a name-keyed record. Scalar/raw columns read their own tag
# (or the munger's 'from'); expanding mungers read one source and fill several
# columns; combining mungers read several sources and fill one. This is the only
# form that supports expanders and combiners.
sub apply_named {
	my ( $self, $hash ) = @_;
	croak 'apply_named requires a hashref' unless ref $hash eq 'HASH';

	my @row;
	for my $s ( @{ $self->{scalar} } ) {
		croak "missing value for '$s->{from}'"
			unless exists $hash->{ $s->{from} };
		my $v = $hash->{ $s->{from} };
		$row[ $self->{pos}{ $s->{tag} } ] = $s->{code} ? $s->{code}->($v) : $v;
	}

	for my $e ( @{ $self->{expand} } ) {
		croak "missing value for '$e->{from}'"
			unless exists $hash->{ $e->{from} };
		my @vals = $e->{code}->( $hash->{ $e->{from} } );
		croak "expanding munger for [@{ $e->{into} }] returned "
			. scalar(@vals)
			. ' value(s), expected '
			. scalar( @{ $e->{into} } )
			unless @vals == @{ $e->{into} };
		for my $i ( 0 .. $#{ $e->{into} } ) {
			$row[ $self->{pos}{ $e->{into}[$i] } ] = $vals[$i];
		}
	} ## end for my $e ( @{ $self->{expand} } )

	for my $c ( @{ $self->{combine} } ) {
		my @vals;
		for my $f ( @{ $c->{from} } ) {
			croak "missing value for '$f'"
				unless exists $hash->{$f};
			push @vals, $hash->{$f};
		}
		$row[ $self->{pos}{ $c->{tag} } ] = $c->{code}->(@vals);
	}

lib/Algorithm/ToNumberMunger.pm  view on Meta::CPAN

	return \@row;
} ## end sub apply_named

# Assemble a row from an already-ordered positional row, applying scalar mungers
# in place. Expanding and combining mungers cannot be expressed positionally
# (there is no named source), so a set that has any is a hard error here -- use
# apply_named.
sub apply_positional {
	my ( $self, $row ) = @_;
	croak 'apply_positional requires an arrayref row' unless ref $row eq 'ARRAY';
	croak 'positional write is unsupported for a set with expanding mungers; ' . 'use write_named'
		if @{ $self->{expand} };
	croak 'positional write is unsupported for a set with multi-input mungers; ' . 'use write_named'
		if @{ $self->{combine} };
	croak 'row has ' . scalar(@$row) . ' fields but info.json declares ' . scalar( @{ $self->{tags} } )
		unless @$row == @{ $self->{tags} };

	my @out = @$row;
	for my $s ( @{ $self->{scalar} } ) {
		next unless $s->{code};
		my $i = $self->{pos}{ $s->{tag} };
		$out[$i] = $s->{code}->( $out[$i] );

t/mungers-plan.t  view on Meta::CPAN

use 5.006;
use strict;
use warnings;
use Test::More;

use Algorithm::ToNumberMunger;
my $M = 'Algorithm::ToNumberMunger';

my $FMT = '%Y-%m-%dT%H:%M:%S';

# A set with a sin/cos time expander, a scalar log column, and a raw column.
my $plan = $M->compile(
	tags    => [qw(time_sin time_cos bytes status)],
	mungers => {
		time_of_week => {
			munger => 'datetime',
			from   => 'timestamp',
			format => $FMT,
			parts  => [qw(sin_week cos_week)],
			into   => [qw(time_sin time_cos)],
		},
		bytes => { munger => 'log', offset => 1 },
	},
);
isa_ok( $plan, 'Algorithm::ToNumberMunger::Plan' );

# ---- apply_named: the expander fills both columns from one source -----------
{
	# 2026-07-05 is Sunday: midnight is frac_week 0 -> sin 0, cos 1.
	my $row = $plan->apply_named( { timestamp => '2026-07-05T00:00:00', bytes => 0, status => 200 } );
	is_deeply( $row, [ 0, 1, 0, 200 ], 'apply_named: sin/cos pair + log1p(0) + raw status, in tag order' );

	eval { $plan->apply_named( { bytes => 0, status => 200 } ) };
	like( $@, qr/missing value for 'timestamp'/, 'apply_named croaks when an expander source is missing' );
}

# ---- from-alias on a scalar munger -----------------------------------------
{
	my $al = $M->compile(
		tags    => ['x'],
		mungers => { x => { munger => 'log', offset => 1, from => 'src' } },
	);
	is( $al->apply_named( { src => 0 } )->[0], 0, 'scalar from-alias reads source' );
	eval { $al->apply_named( { x => 0 } ) };

t/mungers-plan.t  view on Meta::CPAN

	my $orig = [ 3, 5 ];
	my $out  = $p->apply_positional($orig);
	ok( abs( $out->[0] - log(4) ) < 1e-9, 'positional applies the scalar munger' );
	is( $out->[1], 5, 'positional passes a raw column through' );
	is_deeply( $orig, [ 3, 5 ], 'positional does not mutate the caller row' );

	eval { $p->apply_positional( [1] ) };
	like( $@, qr/declares 2/, 'positional arity check' );

	eval { $plan->apply_positional( [ 1, 2, 3, 4 ] ) };
	like( $@, qr/expanding mungers/, 'positional is rejected when the set has expanders' );
}

# ---- multi-input combiners: ratio and combine -------------------------------
{
	my $p = $M->compile(
		tags    => [qw(io_ratio total)],
		mungers => {
			io_ratio => { munger => 'ratio',   from => [qw(bytes_out bytes_in)] },
			total    => { munger => 'combine', from => [qw(bytes_out bytes_in extra)], op => 'sum' },
		},

t/mungers-plan.t  view on Meta::CPAN

					munger => 'datetime',
					format => $FMT,
					parts  => ['epoch'],
					into   => ['nope']
				}
			},
		);
	};
	like( $@, qr/unknown column 'nope'/, 'rejects into on an unknown column' );

	# a key that is neither a tag nor an expander
	eval { $M->compile( tags => ['a'], mungers => { zzz => { munger => 'log' } } ) };
	like( $@, qr/is not a declared tag and has no 'into'/, 'rejects orphan key' );

	# parts / into length mismatch
	eval {
		$M->compile(
			tags    => [qw(a b)],
			mungers => {
				g => {
					munger => 'datetime',



( run in 3.198 seconds using v1.01-cache-2.11-cpan-a9496e3eb41 )