/**
* Validator for PDSL AST
*
* Performs semantic validation including:
* - Probability range checking
* - Annotated disjunction sum validation
* - Variable safety checking
* - Predicate arity consistency
*/
import {
Program,
Model,
Statement,
ProbabilisticFact,
ProbabilisticRule,
AnnotatedDisjunction,
Atom,
Literal,
extractAtomVariables,
extractLiteralVariables,
Location,
} from './probabilisticAST.js';
// ============================================================================
// Validation Result Types
// ============================================================================
export interface ValidationResult {
valid: boolean;
errors: ValidationError[];
warnings: ValidationWarning[];
}
export interface ValidationError {
type: string;
message: string;
location: Location;
suggestion?: string;
}
export interface ValidationWarning {
type: string;
message: string;
location: Location;
}
// ============================================================================
// Predicate Registry
// ============================================================================
interface PredicateInfo {
name: string;
arity: number;
locations: Location[];
}
// ============================================================================
// Validator Class
// ============================================================================
export class Validator {
private errors: ValidationError[] = [];
private warnings: ValidationWarning[] = [];
private predicates: Map<string, PredicateInfo> = new Map();
/**
* Validate an AST
*/
public validate(ast: Program): ValidationResult {
this.errors = [];
this.warnings = [];
this.predicates = new Map();
for (const model of ast.models) {
this.validateModel(model);
}
return {
valid: this.errors.length === 0,
errors: this.errors,
warnings: this.warnings,
};
}
// ==========================================================================
// Model Validation
// ==========================================================================
private validateModel(model: Model): void {
// First pass: collect predicate definitions
for (const stmt of model.statements) {
this.collectPredicates(stmt);
}
// Second pass: validate statements
for (const stmt of model.statements) {
this.validateStatement(stmt);
}
// Check for arity consistency
this.checkArityConsistency();
}
private collectPredicates(stmt: Statement): void {
if (stmt.type === 'ProbabilisticFact' || stmt.type === 'DeterministicFact') {
this.registerPredicate(stmt.atom);
} else if (stmt.type === 'ProbabilisticRule') {
this.registerPredicate(stmt.head);
for (const literal of stmt.body) {
this.registerPredicate(literal.atom);
}
} else if (stmt.type === 'AnnotatedDisjunction') {
for (const choice of stmt.choices) {
this.registerPredicate(choice.atom);
}
} else if (stmt.type === 'Observation') {
this.registerPredicate(stmt.literal.atom);
} else if (stmt.type === 'Query') {
this.registerPredicate(stmt.atom);
}
}
private registerPredicate(atom: Atom): void {
const key = `${atom.predicate}/${atom.args.length}`;
if (this.predicates.has(key)) {
const info = this.predicates.get(key)!;
info.locations.push(atom.location);
} else {
this.predicates.set(key, {
name: atom.predicate,
arity: atom.args.length,
locations: [atom.location],
});
}
}
private checkArityConsistency(): void {
// Group predicates by name
const byName = new Map<string, PredicateInfo[]>();
this.predicates.forEach(info => {
if (!byName.has(info.name)) {
byName.set(info.name, []);
}
byName.get(info.name)!.push(info);
});
// Check for arity inconsistencies
byName.forEach((infos, name) => {
if (infos.length > 1) {
const arities = infos.map(i => i.arity);
const distinctArities = Array.from(new Set(arities));
if (distinctArities.length > 1) {
this.errors.push({
type: 'ArityMismatch',
message: `Predicate '${name}' used with different arities: ${distinctArities.join(', ')}`,
location: infos[0].locations[0],
suggestion: `Use consistent arity for '${name}' throughout the model`,
});
}
}
});
}
// ==========================================================================
// Statement Validation
// ==========================================================================
private validateStatement(stmt: Statement): void {
switch (stmt.type) {
case 'ProbabilisticFact':
this.validateProbabilisticFact(stmt);
break;
case 'ProbabilisticRule':
this.validateProbabilisticRule(stmt);
break;
case 'DeterministicFact':
this.validateAtom(stmt.atom);
break;
case 'AnnotatedDisjunction':
this.validateAnnotatedDisjunction(stmt);
break;
case 'Observation':
this.validateLiteral(stmt.literal);
break;
case 'Query':
this.validateAtom(stmt.atom);
break;
}
}
private validateProbabilisticFact(fact: ProbabilisticFact): void {
this.validateProbability(fact.probability, fact.location);
this.validateAtom(fact.atom);
}
private validateProbabilisticRule(rule: ProbabilisticRule): void {
this.validateProbability(rule.probability, rule.location);
this.validateAtom(rule.head);
for (const literal of rule.body) {
this.validateLiteral(literal);
}
// Check variable safety
this.validateRuleSafety(rule);
}
private validateAnnotatedDisjunction(ad: AnnotatedDisjunction): void {
let sum = 0;
const probabilities: number[] = [];
for (const choice of ad.choices) {
this.validateAtom(choice.atom);
if (typeof choice.probability === 'number') {
this.validateProbability(choice.probability, choice.location);
sum += choice.probability;
probabilities.push(choice.probability);
} else {
// Variable probability (for learning) - skip sum check
this.warnings.push({
type: 'LearningProbability',
message: `Probability variable '${choice.probability}' in annotated disjunction will be learned`,
location: choice.location,
});
return; // Can't validate sum with variables
}
}
// Check sum constraint
if (sum > 1.0001) { // Allow small floating point error
this.errors.push({
type: 'InvalidAnnotatedDisjunction',
message: `Annotated disjunction probabilities sum to ${sum.toFixed(4)}, which exceeds 1.0`,
location: ad.location,
suggestion: `Probabilities must sum to at most 1.0. Current: ${probabilities.map(p => p.toFixed(3)).join(' + ')} = ${sum.toFixed(3)}`,
});
}
// Warn if sum is significantly less than 1.0
if (sum < 0.99 && sum > 0) {
this.warnings.push({
type: 'LowDisjunctionSum',
message: `Annotated disjunction probabilities sum to ${sum.toFixed(4)}, leaving implicit "none" probability of ${(1 - sum).toFixed(4)}`,
location: ad.location,
});
}
}
// ==========================================================================
// Probability Validation
// ==========================================================================
private validateProbability(prob: number | string, location: Location): void {
// If it's a variable name (string), it's for parameter learning - skip validation
if (typeof prob === 'string') {
this.warnings.push({
type: 'LearningProbability',
message: `Probability variable '${prob}' will be learned from data`,
location,
});
return;
}
// Validate numeric probability
if (prob < 0 || prob > 1) {
let suggestion: string | undefined;
if (prob > 1 && prob <= 10) {
suggestion = `Did you mean ${(prob / 10).toFixed(1)}?`;
} else if (prob < 0) {
suggestion = 'Probabilities cannot be negative';
} else {
suggestion = 'Probabilities must be in the range [0.0, 1.0]';
}
this.errors.push({
type: 'InvalidProbability',
message: `Probability ${prob} must be between 0.0 and 1.0`,
location,
suggestion,
});
}
// Warn about extreme probabilities
if (prob === 0) {
this.warnings.push({
type: 'ZeroProbability',
message: 'Probability of 0.0 means this will never occur',
location,
});
} else if (prob === 1) {
this.warnings.push({
type: 'CertainProbability',
message: 'Probability of 1.0 means this is certain - consider a deterministic fact instead',
location,
});
}
}
// ==========================================================================
// Rule Safety Validation
// ==========================================================================
private validateRuleSafety(rule: ProbabilisticRule): void {
// Collect variables from head
const headVars = extractAtomVariables(rule.head);
// Collect variables from body
const bodyVars = new Set<string>();
for (const literal of rule.body) {
const vars = extractLiteralVariables(literal);
vars.forEach(v => bodyVars.add(v));
}
// Check safety: all head variables must appear in positive body literals
const positiveBodyVars = new Set<string>();
for (const literal of rule.body) {
if (!literal.negated) {
const vars = extractLiteralVariables(literal);
vars.forEach(v => positiveBodyVars.add(v));
}
}
headVars.forEach(headVar => {
if (!positiveBodyVars.has(headVar)) {
this.errors.push({
type: 'UnsafeVariable',
message: `Variable '${headVar}' in rule head must appear in a positive (non-negated) body literal`,
location: rule.location,
suggestion: `Add a positive literal containing '${headVar}' to the rule body`,
});
}
});
// Warn about variables only in negated literals
const negatedOnlyVars = new Set<string>();
for (const literal of rule.body) {
if (literal.negated) {
const vars = extractLiteralVariables(literal);
vars.forEach(v => {
if (!positiveBodyVars.has(v)) {
negatedOnlyVars.add(v);
}
});
}
}
negatedOnlyVars.forEach(v => {
this.warnings.push({
type: 'NegatedOnlyVariable',
message: `Variable '${v}' only appears in negated literals - this may cause unexpected behavior`,
location: rule.location,
});
});
}
// ==========================================================================
// Atom and Literal Validation
// ==========================================================================
private validateAtom(atom: Atom): void {
// Check for empty predicate name
if (atom.predicate.length === 0) {
this.errors.push({
type: 'EmptyPredicate',
message: 'Predicate name cannot be empty',
location: atom.location,
});
}
// Check predicate naming convention (should start with lowercase)
if (atom.predicate.length > 0 && /[A-Z]/.test(atom.predicate[0])) {
this.warnings.push({
type: 'PredicateNaming',
message: `Predicate '${atom.predicate}' starts with uppercase - predicates should start with lowercase by convention`,
location: atom.location,
});
}
// Validate each argument
for (const arg of atom.args) {
this.validateTerm(arg);
}
}
private validateLiteral(literal: Literal): void {
this.validateAtom(literal.atom);
}
private validateTerm(term: any): void {
if (term.type === 'Atom') {
this.validateAtom(term);
}
// Variables, constants, and numbers don't need validation
}
}
// ============================================================================
// Utility Functions
// ============================================================================
/**
* Validate a PDSL AST
*/
export function validate(ast: Program): ValidationResult {
const validator = new Validator();
return validator.validate(ast);
}
/**
* Format validation errors and warnings for display
*/
export function formatValidationResult(result: ValidationResult): string {
const lines: string[] = [];
if (result.valid) {
lines.push('✓ Validation passed');
} else {
lines.push(`✗ Validation failed with ${result.errors.length} error(s)`);
}
if (result.errors.length > 0) {
lines.push('');
lines.push('Errors:');
for (const error of result.errors) {
lines.push(` [${error.type}] Line ${error.location.line}, Column ${error.location.column}`);
lines.push(` ${error.message}`);
if (error.suggestion) {
lines.push(` Suggestion: ${error.suggestion}`);
}
}
}
if (result.warnings.length > 0) {
lines.push('');
lines.push('Warnings:');
for (const warning of result.warnings) {
lines.push(` [${warning.type}] Line ${warning.location.line}, Column ${warning.location.column}`);
lines.push(` ${warning.message}`);
}
}
return lines.join('\n');
}