Algorithm-ToNumberMunger

 view release on metacpan or  search on metacpan

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

			. "(that length is n); got '$g' alongside a $n-gram"
			unless length($g) == $n;
		my $c = $counts->{$g};
		croak "ngram munger$where: count for '$g' ('"
			. ( defined $c ? $c : 'undef' )
			. "') is not a non-negative number"
			unless looks_like_number($c) && $c >= 0;
		$sum += $c;
	} ## end for my $g ( keys %$counts )
	croak "ngram munger$where: 'counts' keys must be at least 1 character"
		unless $n >= 1;

	my $V = keys %$counts;
	carp "ngram munger$where: 'counts' has $V keys; a table this large bloats info.json"
		if $V > $FROZEN_FREQ_MAP_WARN_KEYS;

	my $total = defined $spec->{total} ? $spec->{total} : $sum;
	croak "ngram munger$where: 'total' must be numeric"
		unless looks_like_number($total);
	croak "ngram munger$where: 'total' ($total) must be >= sum of counts ($sum)"
		if $total < $sum;

	my $s = defined $spec->{smoothing} ? $spec->{smoothing} : 1;
	croak "ngram munger$where: 'smoothing' must be a number > 0 "
		. '(an unseen gram would otherwise be infinitely surprising)'
		unless looks_like_number($s) && $s > 0;

	my $fold = exists $spec->{fold_case} ? ( $spec->{fold_case} ? 1 : 0 ) : 1;

	# Same smoothed-probability scheme as frozen_freq_map, "unseen" as one extra
	# bucket; surprisal precomputed per listed gram.
	my $denom  = $total + $s * ( $V + 1 );
	my %si     = map { $_ => -log( ( $counts->{$_} + $s ) / $denom ) } keys %$counts;
	my $unseen = -log( $s / $denom );

	return sub {
		my ($v) = @_;
		my $str = defined $v ? "$v" : '';
		$str = lc $str if $fold;
		my $grams = length($str) - $n + 1;
		return 0 if $grams < 1;
		my $tot = 0;
		for my $i ( 0 .. $grams - 1 ) {
			my $g = substr( $str, $i, $n );
			$tot += exists $si{$g} ? $si{$g} : $unseen;
		}
		return $tot / $grams;
	}; ## end sub
} ## end sub _build_ngram

=head2 char

    { munger => 'char', class => 'non_alnum', mode => 'ratio' }
    { munger => 'char', class => 'non_ascii' }               # mode defaults to count

Count the characters of the input that fall in a named C<class>, either as a raw
C<count> (default) or, with C<< mode => 'ratio' >>, as a fraction of the string's
length (C<0> for an empty string). This is the injection / obfuscation detector
behind columns like C<url_non_alnum> (a I<ratio>, so it stays independent of
length) and C<filename_non_ascii> (a I<count>): payloads and homoglyph tricks
are dense with punctuation, percent-encoding, or non-ASCII where normal input is
not. Counting is over B<characters>, so C<non_ascii> means codepoints above 127.

Recognised classes: C<alnum> / C<non_alnum>, C<ascii> / C<non_ascii>, C<digit>,
C<alpha>, C<upper>, C<lower>, C<vowel>, C<consonant>, C<xdigit>, C<space>,
C<punct>. C<vowel> and C<consonant> are the ASCII letters (C<y> counting as a
consonant) -- a vowel/consonant I<ratio> is a DGA corroborator that catches
consonant-heavy random strings C<entropy> alone underrates; C<xdigit> is
C<0-9a-fA-F>, dense in encoded payloads.

=cut

# class name => a counting sub over an (already copied) string. The literal-
# range classes count with tr///, which runs at C speed -- an order of
# magnitude faster than tallying regex matches. tr/// needs its ranges spelled
# at compile time, hence one sub per class rather than a data table. The 'run'
# munger's %RUN_RE mirrors these class names; keep the two in sync.
my %CHAR_COUNT = (
	alnum     => sub { $_[0] =~ tr/A-Za-z0-9// },
	non_alnum => sub { $_[0] =~ tr/A-Za-z0-9//c },
	ascii     => sub { $_[0] =~ tr/\x00-\x7f// },
	non_ascii => sub { $_[0] =~ tr/\x00-\x7f//c },
	digit     => sub { $_[0] =~ tr/0-9// },
	alpha     => sub { $_[0] =~ tr/A-Za-z// },
	upper     => sub { $_[0] =~ tr/A-Z// },
	lower     => sub { $_[0] =~ tr/a-z// },
	vowel     => sub { $_[0] =~ tr/aeiouAEIOU// },
	consonant => sub { $_[0] =~ tr/b-df-hj-np-tv-zB-DF-HJ-NP-TV-Z// },
	xdigit    => sub { $_[0] =~ tr/0-9A-Fa-f// },
	# space and punct match richer classes (\s, [[:punct:]], including their
	# Unicode behavior) that tr/// ranges cannot reproduce; they stay on the
	# regex so their semantics do not change.
	space => sub { my $n = () = $_[0] =~ /\s/g;          $n },
	punct => sub { my $n = () = $_[0] =~ /[[:punct:]]/g; $n },
);

sub _build_char {
	my ( $spec, $where ) = @_;

	my $class = $spec->{class};
	croak "char munger$where requires a 'class'"
		unless defined $class;
	my $count = $CHAR_COUNT{$class}
		or croak "char munger$where: unknown class '$class' (known: " . join( ', ', sort keys %CHAR_COUNT ) . ')';

	my $mode = defined $spec->{mode} ? $spec->{mode} : 'count';
	croak "char munger$where: 'mode' must be 'count' or 'ratio'"
		unless $mode eq 'count' || $mode eq 'ratio';
	my $ratio = $mode eq 'ratio' ? 1 : 0;

	return sub {
		my ($v) = @_;
		my $s   = defined $v ? "$v" : '';
		my $n   = $count->($s);
		return $n unless $ratio;
		my $len = length $s;
		return $len ? $n / $len : 0;
	};
} ## end sub _build_char

=head2 run

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

		my $s   = defined $v ? "$v" : '';
		my $max = 0;
		while ( $s =~ /$re/g ) {
			$max = length $1 if length $1 > $max;
		}
		return $max;
	};
} ## end sub _build_run

=head2 count

    { munger => 'count', of => '/' }             # url_path_depth, topic_depth
    { munger => 'count', of => '.', plus => 1 }  # label_count (dots + 1)

Count non-overlapping occurrences of a literal substring C<of> in the input,
optionally adding a constant C<plus>. This is the segment/depth feature behind
C<url_path_depth> and C<topic_depth> (count of C<`/`>) and C<label_count> (dots
plus one). C<of> is matched literally, not as a pattern, so C<.> means a literal
dot.

=cut

sub _build_count {
	my ( $spec, $where ) = @_;

	my $of = $spec->{of};
	croak "count munger$where requires a non-empty 'of' string"
		unless defined $of && length $of;

	my $plus = defined $spec->{plus} ? $spec->{plus} : 0;
	croak "count munger$where: 'plus' must be numeric"
		unless looks_like_number($plus);

	# index() beats a global regex match here: no pattern engine, and no
	# per-call list of matches just to count them. Advancing by length($of)
	# keeps the non-overlapping semantics m//g had.
	my $oflen = length $of;
	return sub {
		my ($v) = @_;
		my $s   = defined $v ? "$v" : '';
		my $n   = 0;
		my $p   = 0;
		while ( ( $p = index( $s, $of, $p ) ) >= 0 ) {
			$n++;
			$p += $oflen;
		}
		return $n + $plus;
	}; ## end sub
} ## end sub _build_count

=head2 match

    { munger => 'match', pattern => '^xn--' }                       # punycode label
    { munger => 'match', pattern => '%[0-9A-Fa-f]{2}', mode => 'count' }

Match the input against a Perl regular expression C<pattern>: C<1>/C<0> under
the default C<< mode => 'bool' >>, or the number of non-overlapping matches
with C<< mode => 'count' >>. A true C<ignore_case> makes the match
case-insensitive. This is the catch-all shape test behind flags like "is this
label punycode" or "is the Host an IP literal", and counters like
percent-escapes in a URL -- anything L</char> and L</count> are not expressive
enough for. The pattern is compiled at build time, so a broken one fails at
C<write_info> rather than per row.

B<Trust note:> a pattern cannot execute code (Perl requires C<use re 'eval'>
for that, which this module does not enable), but a pathological pattern can
still backtrack catastrophically and stall a writer. Treat munger specs --
like the rest of C<info.json> -- as configuration from a trusted operator,
not as untrusted input.

=cut

sub _build_match {
	my ( $spec, $where ) = @_;

	my $pat = $spec->{pattern};
	croak "match munger$where requires a non-empty 'pattern'"
		unless defined $pat && length $pat;

	my $mode = defined $spec->{mode} ? $spec->{mode} : 'bool';
	croak "match munger$where: 'mode' must be 'bool' or 'count'"
		unless $mode eq 'bool' || $mode eq 'count';

	# qr// on spec text cannot run code -- (?{...}) needs 'use re "eval"',
	# which is not enabled here -- but it can be syntactically invalid, so
	# compile eagerly and croak at build time.
	my $re = eval { $spec->{ignore_case} ? qr/$pat/i : qr/$pat/ };
	croak "match munger$where: cannot compile pattern '$pat': $@"
		unless defined $re;

	if ( $mode eq 'bool' ) {
		return sub {
			my $s = defined $_[0] ? "$_[0]" : '';
			return $s =~ $re ? 1 : 0;
		};
	}
	return sub {
		my $s = defined $_[0] ? "$_[0]" : '';
		my $n = () = $s =~ /$re/g;
		return $n;
	};
} ## end sub _build_match

=head2 bucket

    { munger => 'bucket', bounds => [ 1024, 49152 ] }   # dest_port classes

Map a number to a bucket index by ascending C<bounds>: the result is how many
bounds the value is greater than or equal to. With C<< bounds => [1024, 49152] >>
a value under C<1024> is C<0> (well-known), C<1024>-C<49151> is C<1> (registered),
and C<49152>+ is C<2> (ephemeral) -- the classic port classing, where the literal
port number is meaningless to a threshold split but the I<class> is a real
signal. C<bounds> must be strictly ascending; N bounds yield indices C<0>..C<N>.

This generalises the C<*_enum> status-class mungers, which are the special case
of bucketing a reply code by its leading digit.

=cut

sub _build_bucket {
	my ( $spec, $where ) = @_;



( run in 1.273 second using v1.01-cache-2.11-cpan-600a1bdf6e4 )