%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%
%  This file is part of Logtalk <https://logtalk.org/>
%  SPDX-FileCopyrightText: 1998-2026 Paulo Moura <pmoura@logtalk.org>
%  SPDX-License-Identifier: Apache-2.0
%
%  Licensed under the Apache License, Version 2.0 (the "License");
%  you may not use this file except in compliance with the License.
%  You may obtain a copy of the License at
%
%      http://www.apache.org/licenses/LICENSE-2.0
%
%  Unless required by applicable law or agreed to in writing, software
%  distributed under the License is distributed on an "AS IS" BASIS,
%  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%  See the License for the specific language governing permissions and
%  limitations under the License.
%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%


:- category(anomaly_detector_common,
	implements(anomaly_detector_protocol),
	extends(options)).

	:- info([
		version is 1:0:0,
		author is 'Paulo Moura',
		date is 2026-05-01,
		comment is 'Shared predicates for anomaly detector learning defaults, threshold-based prediction, baseline training-data selection, dataset helpers, and export.'
	]).

	:- protected(anomaly_detector_diagnostics_data/2).
	:- mode(anomaly_detector_diagnostics_data(+compound, -list(compound)), one).
	:- info(anomaly_detector_diagnostics_data/2, [
		comment is 'Hook predicate that importing anomaly detector implementations may override in order to expose diagnostics and metadata for learned detectors.',
		argnames is ['AnomalyDetector', 'Diagnostics']
	]).

	:- protected(anomaly_detector_export_template/2).
	:- mode(anomaly_detector_export_template(+atom, -callable), one).
	:- info(anomaly_detector_export_template/2, [
		comment is 'Hook predicate that importing anomaly detector implementations must define in order to expose the exported detector predicate template for a given functor.',
		argnames is ['Functor', 'Template']
	]).

	:- protected(anomaly_detector_term_template/2).
	:- mode(anomaly_detector_term_template(+compound, -compound), one).
	:- info(anomaly_detector_term_template/2, [
		comment is 'Hook predicate that importing anomaly detector implementations must define in order to expose the learned detector term template used by pretty-printing helpers.',
		argnames is ['AnomalyDetector', 'Template']
	]).

	:- protected(dataset_attributes/2).
	:- mode(dataset_attributes(+object_identifier, -list(pair)), one).
	:- info(dataset_attributes/2, [
		comment is 'Collects the dataset attribute declarations as `Attribute-Values` pairs.',
		argnames is ['Dataset', 'Attributes']
	]).

	:- protected(check_examples_non_empty/2).
	:- mode(check_examples_non_empty(+object_identifier, +list), one).
	:- info(check_examples_non_empty/2, [
		comment is 'Checks that a dataset contains at least one example.',
		argnames is ['Dataset', 'Examples']
	]).

	:- protected(baseline_training_examples/3).
	:- mode(baseline_training_examples(+object_identifier, -list, +list(compound)), one).
	:- info(baseline_training_examples/3, [
		comment is 'Collects training examples selected by the ``baseline_class_values/1`` and ``baseline_selection_policy/1`` options. The ``reject`` policy throws an error when non-baseline examples are present. The ``filter`` policy removes non-baseline examples and throws an error when no baseline examples remain.',
		argnames is ['Dataset', 'Examples', 'Options']
	]).

	:- protected(valid_baseline_class_values/1).
	:- mode(valid_baseline_class_values(+list(atom)), one).
	:- info(valid_baseline_class_values/1, [
		comment is 'Checks that a ``baseline_class_values/1`` option value is a non-empty list of distinct atoms.',
		argnames is ['BaselineClassValues']
	]).

	:- protected(extract_scores/2).
	:- mode(extract_scores(+list, -list), one).
	:- info(extract_scores/2, [
		comment is 'Transforms `Score-Id-Class` tuples into the public `Id-Class-Score` representation.',
		argnames is ['Pairs', 'Scores']
	]).

	:- protected(print_anomaly_detector_template/1).
	:- mode(print_anomaly_detector_template(+compound), one).
	:- info(print_anomaly_detector_template/1, [
		comment is 'Prints the learned anomaly detector term template in a human-readable form.',
		argnames is ['AnomalyDetector']
	]).

	:- uses(format, [
		format/2, format/3
	]).

	:- uses(list, [
		last/2, member/2, memberchk/2
	]).

	:- uses(type, [
		valid/2
	]).

	learn(Dataset, Detector) :-
		::learn(Dataset, Detector, []).

	check_anomaly_detector(Detector) :-
		(	var(Detector) ->
			instantiation_error
		;   ::anomaly_detector_term_template(Detector, _Template) ->
			true
		;   domain_error(anomaly_detector, Detector)
		).

	valid_anomaly_detector(Detector) :-
		catch(::check_anomaly_detector(Detector), _Error, fail).

	diagnostics(Detector, Diagnostics) :-
		::anomaly_detector_diagnostics_data(Detector, Diagnostics).

	diagnostic(Detector, Diagnostic) :-
		::anomaly_detector_diagnostics_data(Detector, Diagnostics),
		member(Diagnostic, Diagnostics).

	predict(Detector, Instance, Prediction) :-
		::anomaly_detector_options(Detector, Options),
		::predict(Detector, Instance, Prediction, Options).

	predict(Detector, Instance, Prediction, UserOptions) :-
		^^check_options(UserOptions),
		::anomaly_detector_options(Detector, ModelOptions),
		(	member(anomaly_threshold(_), UserOptions) ->
			UpdateUserOptions = UserOptions
		;	member(anomaly_threshold(ModelThreshold), ModelOptions) ->
			UpdateUserOptions = [anomaly_threshold(ModelThreshold)| UserOptions]
		;	UpdateUserOptions = UserOptions
		),
		^^merge_options(UpdateUserOptions, Options),
		^^option(anomaly_threshold(Threshold), Options),
		::score(Detector, Instance, Score),
		(	Score >= Threshold ->
			Prediction = anomaly
		;	Prediction = normal
		).

	export_to_file(Dataset, Detector, Functor, File) :-
		::export_to_clauses(Dataset, Detector, Functor, Clauses),
		open(File, write, Stream),
		write_comment_header(Dataset, Functor, Detector, Stream),
		write_clauses(Clauses, Stream),
		close(Stream).

	anomaly_detector_options(Detector, Options) :-
		diagnostics(Detector, Diagnostics),
		memberchk(options(Options), Diagnostics).

	anomaly_detector_diagnostics_data(Detector, [model(Model), options(Options)]) :-
		self(Model),
		Detector =.. [_| Arguments],
		last(Arguments, Options).

	dataset_attributes(Dataset, Attributes) :-
		findall(
			Attribute-Values,
			Dataset::attribute_values(Attribute, Values),
			Attributes
		).

	check_examples_non_empty(Dataset, Examples) :-
		(   Examples == [] ->
			domain_error(non_empty_dataset, Dataset)
		;   true
		).

	baseline_training_examples(Dataset, Examples, Options) :-
		memberchk(baseline_class_values(BaselineClassValues), Options),
		check_baseline_class_values(Dataset, BaselineClassValues),
		memberchk(baseline_selection_policy(Policy), Options),
		baseline_training_examples(Policy, Dataset, BaselineClassValues, Examples),
		check_baseline_training_examples_non_empty(Dataset, Policy, Examples).

	valid_baseline_class_values(BaselineClassValues) :-
		valid(list(atom), BaselineClassValues),
		BaselineClassValues \== [],
		unique_baseline_class_values(BaselineClassValues).

	check_baseline_class_values(Dataset, BaselineClassValues) :-
		Dataset::class_values(ClassValues),
		check_baseline_class_values_list(BaselineClassValues, ClassValues).

	check_baseline_class_values_list([], _ClassValues).
	check_baseline_class_values_list([BaselineClassValue| BaselineClassValues], ClassValues) :-
		(	member(BaselineClassValue, ClassValues) ->
			true
		;	existence_error(class_value, BaselineClassValue)
		),
		check_baseline_class_values_list(BaselineClassValues, ClassValues).

	baseline_training_examples(reject, Dataset, BaselineClassValues, Examples) :-
		(	Dataset::example(_Id, Class, _AttributeValues), \+ member(Class, BaselineClassValues) ->
			domain_error(baseline_only_training_data, Dataset)
		;	findall(
				Id-Class-AttributeValues,
				(
					Dataset::example(Id, Class, AttributeValues),
					member(Class, BaselineClassValues)
				),
				Examples
			)
		).
	baseline_training_examples(filter, Dataset, BaselineClassValues, Examples) :-
		findall(
			Id-Class-AttributeValues,
			(
				Dataset::example(Id, Class, AttributeValues),
				member(Class, BaselineClassValues)
			),
			Examples
		).

	check_baseline_training_examples_non_empty(_Dataset, _Policy, Examples) :-
		Examples \== [],
		!.
	check_baseline_training_examples_non_empty(Dataset, reject, []) :-
		domain_error(non_empty_dataset, Dataset).
	check_baseline_training_examples_non_empty(Dataset, filter, []) :-
		(	Dataset::example(_Id, _Class, _AttributeValues) ->
			domain_error(non_empty_baseline_training_data, Dataset)
		;	domain_error(non_empty_dataset, Dataset)
		).

	unique_baseline_class_values([]).
	unique_baseline_class_values([BaselineClassValue| BaselineClassValues]) :-
		\+ member(BaselineClassValue, BaselineClassValues),
		unique_baseline_class_values(BaselineClassValues).

	extract_scores([], []).
	extract_scores([Score-Id-Class| Pairs], [Id-Class-Score| Scores]) :-
		extract_scores(Pairs, Scores).

	print_anomaly_detector_template(Detector) :-
		::anomaly_detector_term_template(Detector, Template),
		format('Template:           ~w~n', [Template]).

	write_comment_header(Dataset, Functor, Detector, Stream) :-
		::anomaly_detector_export_template(Functor, Template),
		functor(Template, _, Arity),
		format(Stream, '% exported anomaly detector predicate: ~q/~d~n', [Functor, Arity]),
		format(Stream, '% training dataset: ~q~n', [Dataset]),
		::anomaly_detector_options(Detector, Options),
		format(Stream, '% options: ~q~n', [Options]),
		format(Stream, '% ~w~n', [Template]).

	write_clauses([], _Stream).
	write_clauses([Clause| Clauses], Stream) :-
		format(Stream, '~q.~n', [Clause]),
		write_clauses(Clauses, Stream).

:- end_category.
