svn commit: r1936252 - in spamassassin/trunk: . lib/Mail lib/Mail/SpamAssassin lib/Mail/SpamAssassin/Plugin t
[email protected] Fri, 17 Jul 2026 10:36:54 -0000
| Newsgroups | gmane.mail.spam.spamassassin.cvs |
|---|---|
| Message-ID | <178428461469.3758796.7245632945274368613@svn03-he-fi> |
Author: gbechis
Date: Fri Jul 17 10:36:54 2026
New Revision: 1936252
Log:
make NeuralNetwork plugin work even without Bayes plugin enabled
add a `--plugin` option to sa-learn in order to dump NeuralNetwork stats
Modified:
spamassassin/trunk/lib/Mail/SpamAssassin.pm
spamassassin/trunk/lib/Mail/SpamAssassin/Conf.pm
spamassassin/trunk/lib/Mail/SpamAssassin/PerMsgStatus.pm
spamassassin/trunk/lib/Mail/SpamAssassin/Plugin/NeuralNetwork.pm
spamassassin/trunk/sa-learn.raw
spamassassin/trunk/t/neuralnetwork.t
Modified: spamassassin/trunk/lib/Mail/SpamAssassin.pm
==============================================================================
--- spamassassin/trunk/lib/Mail/SpamAssassin.pm Fri Jul 17 10:19:26 2026 (r1936251)
+++ spamassassin/trunk/lib/Mail/SpamAssassin.pm Fri Jul 17 10:36:54 2026 (r1936252)
@@ -849,6 +849,37 @@ sub dump_bayes_db {
$self->{bayes_scanner}->dump_bayes_db(@opts) if $self->{bayes_scanner};
}
+=item $f-E<gt>dump_neuralnetwork_db()
+
+Dump the contents of the NeuralNetwork plugin's data/stats.
+
+=cut
+
+sub dump_neuralnetwork_db {
+ my($self, $magic, $toks, $regex) = @_;
+ return $self->call_plugins("neuralnetwork_dump_database",
+ { magic => $magic, toks => $toks, regex => $regex });
+}
+
+=item $f-E<gt>learner_scoreset_active()
+
+Returns true if a learner plugin, (Bayes and/or NeuralNetwork), is enabled
+and has enough data to actively classify mail.
+
+=cut
+
+sub learner_scoreset_active {
+ my $self = shift;
+
+ return 1 if $self->{bayes_scanner} && $self->{bayes_scanner}->is_scan_available()
+ && $self->{conf}->{use_bayes_rules};
+
+ return 1 if $self->{conf}->{use_neuralnetwork}
+ && $self->call_plugins("neuralnetwork_is_scan_available");
+
+ return 0;
+}
+
=item $f-E<gt>signal_user_changed ( [ { opt =E<gt> val, ... } ] )
Signals that the current user has changed (possibly using C<setuid>), meaning
@@ -907,8 +938,11 @@ sub signal_user_changed {
}
# reopen bayes dbs for this user
+ # Note: this dispatcher object is shared by all learner plugins (Bayes and
+ # others), so it is gated on the generic use_learner switch, not use_bayes;
+ # each plugin's hooks are responsible for checking their own specific switch.
$self->{bayes_scanner}->finish() if $self->{bayes_scanner};
- if ($self->{conf}->{use_bayes}) {
+ if ($self->{conf}->{use_learner}) {
require Mail::SpamAssassin::Bayes;
$self->{bayes_scanner} = Mail::SpamAssassin::Bayes->new($self);
} else {
@@ -919,7 +953,7 @@ sub signal_user_changed {
$self->{'learn_to_journal'} = $self->{conf}->{bayes_learn_to_journal};
$set |= 1 unless $self->{local_tests_only};
- $set |= 2 if $self->{bayes_scanner} && $self->{bayes_scanner}->is_scan_available() && $self->{conf}->{use_bayes_rules};
+ $set |= 2 if $self->learner_scoreset_active();
$self->{conf}->set_score_set ($set);
@@ -1878,8 +1912,9 @@ sub init {
die "config: no rules were found! Do you need to run 'sa-update'?\n";
}
- # Initialize the Bayes subsystem
- if ($self->{conf}->{use_bayes}) {
+ # Initialize the learner subsystem dispatcher; shared by all learner
+ # plugins (Bayes and others).
+ if ($self->{conf}->{use_learner}) {
require Mail::SpamAssassin::Bayes;
$self->{bayes_scanner} = Mail::SpamAssassin::Bayes->new($self);
}
@@ -1888,7 +1923,7 @@ sub init {
# Figure out/set our initial scoreset
my $set = 0;
$set |= 1 unless $self->{local_tests_only};
- $set |= 2 if $self->{bayes_scanner} && $self->{bayes_scanner}->is_scan_available() && $self->{conf}->{use_bayes_rules};
+ $set |= 2 if $self->learner_scoreset_active();
$self->{conf}->set_score_set ($set);
if ($self->{only_these_rules}) {
Modified: spamassassin/trunk/lib/Mail/SpamAssassin/Conf.pm
==============================================================================
--- spamassassin/trunk/lib/Mail/SpamAssassin/Conf.pm Fri Jul 17 10:19:26 2026 (r1936251)
+++ spamassassin/trunk/lib/Mail/SpamAssassin/Conf.pm Fri Jul 17 10:36:54 2026 (r1936252)
@@ -206,11 +206,12 @@ for a test.
If four valid scores are listed, then the score that is used depends
on how SpamAssassin is being used. The first score is used when
-both Bayes and network tests are disabled (score set 0). The second
-score is used when Bayes is disabled, but network tests are enabled
-(score set 1). The third score is used when Bayes is enabled and
-network tests are disabled (score set 2). The fourth score is used
-when Bayes is enabled and network tests are enabled (score set 3).
+both a learner (Bayes and/or NeuralNetwork) and network tests are disabled
+(score set 0). The second score is used when no learner is active, but
+network tests are enabled (score set 1). The third score is used when a
+learner (Bayes and/or NeuralNetwork) is active and network tests are
+disabled (score set 2). The fourth score is used when a learner is active
+and network tests are enabled (score set 3).
Setting a rule's score to 0 will disable that rule from running.
Modified: spamassassin/trunk/lib/Mail/SpamAssassin/PerMsgStatus.pm
==============================================================================
--- spamassassin/trunk/lib/Mail/SpamAssassin/PerMsgStatus.pm Fri Jul 17 10:19:26 2026 (r1936251)
+++ spamassassin/trunk/lib/Mail/SpamAssassin/PerMsgStatus.pm Fri Jul 17 10:36:54 2026 (r1936252)
@@ -417,12 +417,12 @@ sub check_timed {
$self->{msg}->delete_header('X-Spam-.*');
# Resident Mail::SpamAssassin code will possibly never change score
- # sets, even if bayes becomes available. So we should do a quick check
- # to see if we should go from {0,1} to {2,3}. We of course don't need
- # to do this switch if we're already using bayes ... ;)
+ # sets, even if a learner (Bayes or NeuralNetwork) becomes available. So
+ # we should do a quick check to see if we should go from {0,1} to {2,3}.
+ # We of course don't need to do this switch if we're already using one ... ;)
my $set = $self->{conf}->get_score_set();
- if (($set & 2) == 0 && $self->{main}->{bayes_scanner} && $self->{main}->{bayes_scanner}->is_scan_available() && $self->{conf}->{use_bayes_rules}) {
- dbg("check: scoreset $set but bayes is available, switching scoresets");
+ if (($set & 2) == 0 && $self->{main}->learner_scoreset_active()) {
+ dbg("check: scoreset $set but a learner is available, switching scoresets");
$self->{conf}->set_score_set ($set|2);
}
dbg("check: using scoreset $set in M:S:Pms");
Modified: spamassassin/trunk/lib/Mail/SpamAssassin/Plugin/NeuralNetwork.pm
==============================================================================
--- spamassassin/trunk/lib/Mail/SpamAssassin/Plugin/NeuralNetwork.pm Fri Jul 17 10:19:26 2026 (r1936251)
+++ spamassassin/trunk/lib/Mail/SpamAssassin/Plugin/NeuralNetwork.pm Fri Jul 17 10:36:54 2026 (r1936252)
@@ -31,10 +31,11 @@ This plugin checks emails using Neural N
=head1 CAVEATS
-The SpamAssassin learning subsystem routes all training through the Bayes
-scanner infrastructure. As a result, C<Mail::SpamAssassin::Plugin::Bayes>
-must be loaded and C<use_bayes 1> must be set for this plugin's training to
-be triggered.
+Training, forgetting and prediction only require the generic C<use_learner 1>
+switch (on by default) and C<use_neuralnetwork 1>.
+
+C<sa-learn --dump --plugin NeuralNetwork> can be used to display this
+plugin's stats and vocabulary data.
=cut
@@ -61,6 +62,7 @@ use Storable qw(store retrieve);
use File::Copy qw(copy);
use File::Spec;
use Errno qw(EXDEV);
+use Encode qw(encode);
use Mail::SpamAssassin;
use Mail::SpamAssassin::Plugin;
@@ -1055,6 +1057,99 @@ sub forget_message {
return 0;
}
+# Plugin hook, invoked via Mail::SpamAssassin::dump_neuralnetwork_db
+# (sa-learn's `--dump --plugin NeuralNetwork`). Deliberately a distinct
+# hook name from Bayes's "learner_dump_database" so plain `sa-learn --dump`
+# never picks this up.
+sub neuralnetwork_dump_database {
+ my ($self, $params) = @_;
+ my $conf = $self->{main}->{conf};
+
+ return 0 unless $conf->{use_neuralnetwork};
+
+ my $magic = $params->{magic};
+ my $toks = $params->{toks};
+ my $regex = $params->{regex};
+
+ my $nn_data_dir = $conf->{neuralnetwork_data_dir};
+ unless (defined $nn_data_dir) {
+ dbg("neuralnetwork_data_dir not set, nothing to dump");
+ return 0;
+ }
+ $nn_data_dir = Mail::SpamAssassin::Util::untaint_file_path($nn_data_dir);
+ unless (-d $nn_data_dir) {
+ info("Cannot access directory $nn_data_dir");
+ return 0;
+ }
+
+ $self->_init_sql_connection($conf) if defined $conf->{neuralnetwork_dsn};
+
+ my $vocabulary = $self->_load_vocabulary($conf, $nn_data_dir, 0);
+ my $meta = $self->_load_meta($conf);
+
+ my $template = '%3.3f %10u %10u %10u %s'."\n";
+
+ if ($magic) {
+ printf($template, 0.0, 0, $vocabulary->{_spam_count}, 0,
+ 'non-token data: neuralnetwork nspam') or die "Error writing: $!";
+ printf($template, 0.0, 0, $vocabulary->{_ham_count}, 0,
+ 'non-token data: neuralnetwork nham') or die "Error writing: $!";
+ printf($template, 0.0, 0, $vocabulary->{_doc_count}, 0,
+ 'non-token data: neuralnetwork ndocs') or die "Error writing: $!";
+ printf($template, 0.0, 0, scalar(keys %{$vocabulary->{terms}}), 0,
+ 'non-token data: neuralnetwork nvocab') or die "Error writing: $!";
+ printf($template, 0.0, 0, $meta->{_learns_since_retrain}, 0,
+ 'non-token data: neuralnetwork learns since retrain') or die "Error writing: $!";
+ printf($template, 0.0, 0, scalar(@{$meta->{_tbuf}{spam} || []}), 0,
+ 'non-token data: neuralnetwork training buffer spam') or die "Error writing: $!";
+ printf($template, 0.0, 0, scalar(@{$meta->{_tbuf}{ham} || []}), 0,
+ 'non-token data: neuralnetwork training buffer ham') or die "Error writing: $!";
+
+ my $dataset_path = Mail::SpamAssassin::Util::untaint_file_path($self->_model_path($nn_data_dir));
+ if ($HAS_AI_FANN && -f $dataset_path) {
+ my $mtime = (stat($dataset_path))[9] || 0;
+ my $inputs = eval { AI::FANN->new_from_file($dataset_path)->num_inputs() } || 0;
+ printf($template, 0.0, 0, $mtime, 0,
+ 'non-token data: neuralnetwork model mtime') or die "Error writing: $!";
+ printf($template, 0.0, 0, $inputs, 0,
+ 'non-token data: neuralnetwork model inputs') or die "Error writing: $!";
+ }
+ }
+
+ if ($toks) {
+ my $terms = $vocabulary->{terms} || {};
+ foreach my $term (sort keys %$terms) {
+ next if defined $regex && $term !~ /$regex/o;
+ my $t = $terms->{$term} || {};
+ my $out_term = utf8::is_utf8($term) ? encode('UTF-8', $term) : $term;
+ printf($template, 0.0, $t->{total} || 0, $t->{spam} || 0, $t->{docs} || 0,
+ $out_term) or die "Error writing: $!";
+ }
+ }
+
+ return 1;
+}
+
+# Plugin hook, invoked via Mail::SpamAssassin::learner_scoreset_active to
+# decide whether score sets 2/3 (the "learner enabled" scoresets) should be
+# used. True when this plugin is enabled and has a usable trained model for
+# the current user.
+sub neuralnetwork_is_scan_available {
+ my ($self, $params) = @_;
+ my $conf = $self->{main}->{conf};
+
+ return 0 unless $conf->{use_neuralnetwork};
+ return 0 unless $HAS_AI_FANN;
+
+ my $nn_data_dir = $conf->{neuralnetwork_data_dir};
+ return 0 unless defined $nn_data_dir;
+ $nn_data_dir = Mail::SpamAssassin::Util::untaint_file_path($nn_data_dir);
+ return 0 unless -d $nn_data_dir;
+
+ my $dataset_path = Mail::SpamAssassin::Util::untaint_file_path($self->_model_path($nn_data_dir));
+ return -f $dataset_path ? 1 : 0;
+}
+
sub check_neuralnetwork_spam {
my ($self, $pms) = @_;
Modified: spamassassin/trunk/sa-learn.raw
==============================================================================
--- spamassassin/trunk/sa-learn.raw Fri Jul 17 10:19:26 2026 (r1936251)
+++ spamassassin/trunk/sa-learn.raw Fri Jul 17 10:36:54 2026 (r1936252)
@@ -121,6 +121,7 @@ GetOptions(
'version|V' => \$opt{'version'},
'dump:s' => \$opt{'dump'},
+ 'plugin=s' => \$opt{'plugin'},
'import' => \$opt{'import'},
'backup' => \$opt{'backup'},
@@ -153,6 +154,17 @@ if (defined $opt{'debug'}) {
$opt{'debug'} ||= 'all';
}
+# --plugin selects which classifier plugin --dump (and friends) operate on;
+# defaults to Bayes for backwards compatibility.
+$opt{'plugin'} = 'Bayes' unless defined $opt{'plugin'};
+if ($opt{'plugin'} =~ /^bayes$/i) {
+ $opt{'plugin'} = 'Bayes';
+} elsif ($opt{'plugin'} =~ /^neuralnetwork$/i) {
+ $opt{'plugin'} = 'NeuralNetwork';
+} else {
+ usage( 0, "Unknown --plugin '" . $opt{'plugin'} . "', must be Bayes or NeuralNetwork" );
+}
+
if ( $opt{'force-expire'} ) {
$synconly = 1;
}
@@ -207,9 +219,11 @@ if ( defined $bayes_override_path ) {
}
# These options require bayes_scanner, which requires "use_bayes 1", but
-# that's not necessary for these commands.
-if (defined $opt{'dump'} || defined $opt{'import'} || defined $opt{'clear'} ||
- defined $opt{'backup'} || defined $opt{'restore'}) {
+# that's not necessary for these commands. --plugin NeuralNetwork redirects
+# --dump to the NeuralNetwork plugin instead, so it's excluded here.
+if (defined $opt{'import'} || defined $opt{'clear'} ||
+ defined $opt{'backup'} || defined $opt{'restore'} ||
+ (defined $opt{'dump'} && $opt{'plugin'} eq 'Bayes')) {
$post_config .= "use_bayes 1\n";
}
@@ -269,9 +283,21 @@ if ( defined $opt{'dump'} ) {
exit 1;
}
- if (!$spamtest->dump_bayes_db( $magic, $toks, $opt{'regexp'}) ) {
+ # --plugin selects which plugin --dump displays data for; the
+ # NeuralNetwork plugin's own stats/vocabulary instead of the Bayes
+ # database.
+ if ($opt{'plugin'} eq 'NeuralNetwork' && !$spamtest->{conf}->{use_neuralnetwork}) {
$spamtest->finish_learner();
- die "ERROR: Bayes dump returned an error, please re-run with -D for more information\n";
+ die "ERROR: --plugin NeuralNetwork requires 'use_neuralnetwork 1' in the configuration\n";
+ }
+
+ my ( $dump_ok, $what ) = $opt{'plugin'} eq 'NeuralNetwork'
+ ? ( $spamtest->dump_neuralnetwork_db( $magic, $toks, $opt{'regexp'} ), 'NeuralNetwork' )
+ : ( $spamtest->dump_bayes_db( $magic, $toks, $opt{'regexp'} ), 'Bayes' );
+
+ if (!$dump_ok) {
+ $spamtest->finish_learner();
+ die "ERROR: $what dump returned an error, please re-run with -D for more information\n";
}
$spamtest->finish_learner();
@@ -281,6 +307,23 @@ if ( defined $opt{'dump'} ) {
exit 0;
}
+# --plugin only changes the behaviour of --dump; the NeuralNetwork plugin
+# has no equivalent of these Bayes-store-specific operations.
+if ($opt{'plugin'} eq 'NeuralNetwork' &&
+ (defined $opt{'import'} || defined $opt{'clear'} ||
+ defined $opt{'backup'} || defined $opt{'restore'})) {
+ $spamtest->finish_learner();
+ die "ERROR: --plugin NeuralNetwork is not supported together with --import, --clear, --backup or --restore\n";
+}
+
+# --import/--clear/--backup/--restore operate directly on the Bayes
+# on-disk store, so the Bayes plugin must actually be loaded for them.
+if ((defined $opt{'import'} || defined $opt{'clear'} || defined $opt{'backup'} ||
+ defined $opt{'restore'}) && !$spamtest->{bayes_scanner}->{store}) {
+ $spamtest->finish_learner();
+ die "ERROR: this command requires the Bayes plugin to be loaded and enabled\n";
+}
+
if ( defined $opt{'import'} ) {
my $ret = $spamtest->{bayes_scanner}->{store}->perform_upgrade();
$spamtest->finish_learner();
@@ -337,8 +380,8 @@ if (defined $opt{'restore'}) {
exit 0;
}
-if ( !$spamtest->{conf}->{use_bayes} ) {
- warn "ERROR: configuration specifies 'use_bayes 0', sa-learn disabled\n";
+if ( !$spamtest->{conf}->{use_learner} ) {
+ warn "ERROR: configuration specifies 'use_learner 0', sa-learn disabled\n";
exit 1;
}
@@ -631,8 +674,12 @@ Options:
--force-expire Force a database sync and expiry run
--dbpath <path> Allows commandline override (in bayes_path form)
for where to read the Bayes DB from
- --dump [all|data|magic] Display the contents of the Bayes database
- Takes optional argument for what to display
+ --dump [all|data|magic] Display the contents of the selected plugin's
+ database (see --plugin). Takes optional argument
+ for what to display
+ --plugin <name> Which plugin --dump displays data for: Bayes
+ (default) or NeuralNetwork. Not supported with
+ --import, --clear, --backup or --restore.
--regexp <re> For dump only, specifies which tokens to
dump based on a regular expression.
-f file, --folders=file Read list of files/directories from file
@@ -775,14 +822,26 @@ Allows a commandline override of the I<b
=item B<--dump> I<option>
-Display the contents of the Bayes database. Without an option or with
-the I<all> option, all magic tokens and data tokens will be displayed.
-I<magic> will only display magic tokens, and I<data> will only display
-the data tokens.
+Display the contents of the database of the plugin selected with
+B<--plugin> (Bayes by default). Without an option or with the I<all>
+option, all magic tokens and data tokens will be displayed. I<magic> will
+only display magic tokens, and I<data> will only display the data tokens.
Can also use the B<--regexp> I<RE> option to specify which tokens to
display based on a regular expression.
+=item B<--plugin> I<name>
+
+Selects which plugin's data B<--dump> displays: I<Bayes> (the default) or
+I<NeuralNetwork>. With I<NeuralNetwork>, I<magic> shows spam/ham/doc
+counts, vocabulary size and model info, and I<data> shows the vocabulary
+terms (requires C<use_neuralnetwork 1>).
+
+B<--plugin> only affects B<--dump>, and I<NeuralNetwork> is not supported
+together with B<--import>, B<--clear>, B<--backup> or B<--restore> -- the
+NeuralNetwork plugin has no equivalent of those Bayes-store-specific
+operations.
+
=item B<--clear>
Clear an existing Bayes database by removing all traces of the database.
Modified: spamassassin/trunk/t/neuralnetwork.t
==============================================================================
--- spamassassin/trunk/t/neuralnetwork.t Fri Jul 17 10:19:26 2026 (r1936251)
+++ spamassassin/trunk/t/neuralnetwork.t Fri Jul 17 10:36:54 2026 (r1936252)
@@ -10,7 +10,7 @@ use Test::More;
use constant HAS_SQLITE => eval { require DBD::SQLite; 1 };
-plan tests => 18;
+plan tests => 29;
sub nn_reinit {
my $extra = shift || '';
@@ -152,3 +152,43 @@ SKIP: {
}
is($correct_ham, 2, 'SQLite backend: all ham classified correctly');
}
+
+# Verify NeuralNetwork training/classification works fully independent of
+# Bayes (use_bayes 0). Regression test for the bayes_scanner dispatcher
+# being gated on the generic use_learner switch instead of use_bayes --
+# previously, use_bayes 0 silently prevented learn_message/forget_message
+# from ever firing for ANY plugin, not just Bayes.
+
+nn_reinit("use_bayes 0");
+ok(salearnrun("-L --spam data/spam/001", \&check_examined_token));
+ok(salearnrun("-L --ham data/nice/001", \&check_examined_token));
+
+%patterns = ( q{ 1.0 NN_SPAM }, '' );
+%anti_patterns = ( q{ -1.0 NN_HAM }, '' );
+sarun("-L -t < data/spam/001", \&patterns_run_cb);
+ok_all_patterns();
+
+%patterns = ( q{ -1.0 NN_HAM }, '' );
+%anti_patterns = ( q{ 1.0 NN_SPAM }, '' );
+sarun("-L -t < data/nice/001", \&patterns_run_cb);
+ok_all_patterns();
+
+# --dump --plugin NeuralNetwork must show NeuralNetwork stats, and plain
+# --dump (--plugin defaults to Bayes) must stay Bayes-only regardless of
+# whether the NeuralNetwork plugin is enabled.
+
+nn_reinit();
+salearnrun("--spam data/spam/001", undef);
+salearnrun("--ham data/nice/001", undef);
+
+my $nn_dump_output = '';
+ok(salearnrun("--dump magic --plugin NeuralNetwork", sub { $nn_dump_output = join('', <IN>); }));
+like($nn_dump_output, qr/non-token data: neuralnetwork nspam/,
+ '--dump --plugin NeuralNetwork shows nspam stat');
+like($nn_dump_output, qr/non-token data: neuralnetwork nham/,
+ '--dump --plugin NeuralNetwork shows nham stat');
+
+my $bayes_dump_output = '';
+ok(salearnrun("--dump magic", sub { $bayes_dump_output = join('', <IN>); }));
+unlike($bayes_dump_output, qr/neuralnetwork/i,
+ '--dump defaults to Bayes-only, no neuralnetwork markers leak in');