|
| 1 | +/* |
| 2 | + * ODK ROBOT Plugin |
| 3 | + * Copyright © 2025 Damien Goutte-Gattat |
| 4 | + * |
| 5 | + * This program is free software; you can redistribute it and/or modify |
| 6 | + * it under the terms of the GNU General Public License as published by |
| 7 | + * the Free Software Foundation, either version 3 of the License, or |
| 8 | + * (at your option) any later version. |
| 9 | + * |
| 10 | + * This program is distributed in the hope that it will be useful, |
| 11 | + * but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 12 | + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 13 | + * GNU General Public License for more details. |
| 14 | + * |
| 15 | + * You should have received a copy of the GNU General Public License |
| 16 | + * along with this program. If not, see <http://www.gnu.org/licenses/>. |
| 17 | + */ |
| 18 | + |
| 19 | +package org.incenp.obofoundry.odk; |
| 20 | + |
| 21 | +import java.io.BufferedWriter; |
| 22 | +import java.io.FileWriter; |
| 23 | +import java.io.IOException; |
| 24 | +import java.util.ArrayList; |
| 25 | +import java.util.HashSet; |
| 26 | +import java.util.List; |
| 27 | +import java.util.Set; |
| 28 | + |
| 29 | +import org.apache.commons.cli.CommandLine; |
| 30 | +import org.obolibrary.robot.CommandLineHelper; |
| 31 | +import org.obolibrary.robot.CommandState; |
| 32 | +import org.obolibrary.robot.MergeOperation; |
| 33 | +import org.semanticweb.owlapi.model.IRI; |
| 34 | +import org.semanticweb.owlapi.model.OWLAnnotation; |
| 35 | +import org.semanticweb.owlapi.model.OWLAnnotationValue; |
| 36 | +import org.semanticweb.owlapi.model.OWLClass; |
| 37 | +import org.semanticweb.owlapi.model.OWLDataFactory; |
| 38 | +import org.semanticweb.owlapi.model.OWLOntology; |
| 39 | +import org.semanticweb.owlapi.model.parameters.Imports; |
| 40 | +import org.semanticweb.owlapi.reasoner.OWLReasoner; |
| 41 | +import org.slf4j.Logger; |
| 42 | +import org.slf4j.LoggerFactory; |
| 43 | + |
| 44 | +/** |
| 45 | + * A command to check the alignment of an ontology against another, upper-level |
| 46 | + * ontology. The ontology is said to be “aligned” if all its classes are |
| 47 | + * subclasses of one of the upper ontology’s classes. |
| 48 | + * <p> |
| 49 | + * The command can also check “self-alignment” (option |
| 50 | + * <code>--use-self true</code>): the ontology is “self-aligned” if all its |
| 51 | + * classes are subclasses of one of the self-declared “preferred roots” |
| 52 | + * (declared within the ontology itself with the <code>IAO:0000700</code> |
| 53 | + * annotation). |
| 54 | + * <p> |
| 55 | + * Lastly, the command can check alignment against arbitrary root terms which |
| 56 | + * can be specified with the <code>--term</code> or <code>--term-file</code> |
| 57 | + * options. |
| 58 | + */ |
| 59 | +public class CheckAlignmentCommand extends BasePlugin { |
| 60 | + |
| 61 | + private static final Logger logger = LoggerFactory.getLogger(CheckAlignmentCommand.class); |
| 62 | + |
| 63 | + private Set<String> basePrefixes = new HashSet<>(); |
| 64 | + private OWLDataFactory factory; |
| 65 | + |
| 66 | + public CheckAlignmentCommand() { |
| 67 | + super("check-align", "validate alignment with an upper ontology or with explicit roots", |
| 68 | + "robot validate [--upper-ontology[-iri] ONT] [--report-output FILE]"); |
| 69 | + |
| 70 | + options.addOption("u", "upper-ontology", true, "load the upper ontology from the specified file"); |
| 71 | + options.addOption("U", "upper-ontology-iri", true, "load the upper ontology from the specified IRI"); |
| 72 | + options.addOption("C", "use-cob", true, "use COB as the upper ontology"); |
| 73 | + |
| 74 | + options.addOption("t", "term", true, "check alignment against specified term"); |
| 75 | + options.addOption("T", "term-file", true, "check alignment against specified list of terms"); |
| 76 | + |
| 77 | + options.addOption("S", "use-self", true, "check self-alignment against declared roots"); |
| 78 | + |
| 79 | + options.addOption("b", "base-iri", true, "only check classes in the specified namespace(s)"); |
| 80 | + options.addOption("d", "ignore-dangling", true, "if true, ignore dangling classes"); |
| 81 | + |
| 82 | + options.addOption("r", "reasoner", true, "the reasoner to use"); |
| 83 | + options.addOption("O", "report-output", true, "write report to the specified file"); |
| 84 | + options.addOption("x", "fail", true, "if true (default), fail if the ontology is misaligned"); |
| 85 | + } |
| 86 | + |
| 87 | + @Override |
| 88 | + public void performOperation(CommandState state, CommandLine line) throws Exception { |
| 89 | + boolean ignoreDangling = CommandLineHelper.getBooleanValue(line, "ignore-dangling", false); |
| 90 | + boolean failOnError = CommandLineHelper.getBooleanValue(line, "fail", true); |
| 91 | + if ( line.hasOption("base-iri") ) { |
| 92 | + for ( String iri : line.getOptionValues("base-iri") ) { |
| 93 | + basePrefixes.add(iri); |
| 94 | + } |
| 95 | + } |
| 96 | + factory = state.getOntology().getOWLOntologyManager().getOWLDataFactory(); |
| 97 | + |
| 98 | + // The classes we need to check alignment against |
| 99 | + Set<OWLClass> upperClasses = new HashSet<>(); |
| 100 | + for ( IRI explicitRoot : CommandLineHelper.getTerms(ioHelper, line, true) ) { |
| 101 | + upperClasses.add(factory.getOWLClass(explicitRoot)); |
| 102 | + } |
| 103 | + if ( CommandLineHelper.getBooleanValue(line, "use-self", false) ) { |
| 104 | + upperClasses.addAll(getPreferredRoots(state.getOntology())); |
| 105 | + } |
| 106 | + |
| 107 | + OWLOntology ontology = getUpperOntology(line); |
| 108 | + if ( ontology != null ) { |
| 109 | + // All classes from the upper ontology are treated as valid roots |
| 110 | + upperClasses.addAll(ontology.getClassesInSignature(Imports.INCLUDED)); |
| 111 | + upperClasses.remove(factory.getOWLThing()); |
| 112 | + |
| 113 | + // We merge the current ontology into the upper ontology rather than the other |
| 114 | + // way around, so that the current ontology remains unchanged and can be used |
| 115 | + // for further operations downstream in the ROBOT pipeline. |
| 116 | + MergeOperation.mergeInto(state.getOntology(), ontology, true, true); |
| 117 | + } else { |
| 118 | + // No upper ontology, validate against explicit and declared roots only |
| 119 | + ontology = state.getOntology(); |
| 120 | + } |
| 121 | + |
| 122 | + if ( upperClasses.isEmpty() ) { |
| 123 | + logger.error("No roots to validate against"); |
| 124 | + return; |
| 125 | + } |
| 126 | + if ( logger.isDebugEnabled() ) { |
| 127 | + for ( OWLClass klass : upperClasses ) { |
| 128 | + logger.debug("Using root class {}", klass.getIRI().toQuotedString()); |
| 129 | + } |
| 130 | + } |
| 131 | + |
| 132 | + OWLReasoner reasoner = CommandLineHelper.getReasonerFactory(line).createReasoner(ontology); |
| 133 | + Set<OWLClass> unalignedClasses = getUnalignedClasses(ontology, reasoner, upperClasses, ignoreDangling); |
| 134 | + |
| 135 | + if ( line.hasOption("report-output") ) { |
| 136 | + // If a report has been requested, we always produce it, even if no unaligned |
| 137 | + // classes were found |
| 138 | + BufferedWriter writer = new BufferedWriter(new FileWriter(line.getOptionValue("report-output"))); |
| 139 | + List<String> unalignedIRIs = new ArrayList<>(); |
| 140 | + for ( OWLClass unalignedClass : unalignedClasses ) { |
| 141 | + unalignedIRIs.add(unalignedClass.getIRI().toString()); |
| 142 | + } |
| 143 | + unalignedIRIs.sort((a, b) -> a.compareTo(b)); |
| 144 | + for ( String iri : unalignedIRIs ) { |
| 145 | + writer.write(iri); |
| 146 | + writer.write('\n'); |
| 147 | + } |
| 148 | + writer.close(); |
| 149 | + } |
| 150 | + |
| 151 | + if ( !unalignedClasses.isEmpty() ) { |
| 152 | + logger.error("Ontology contains {} top-level unaligned class(es)", unalignedClasses.size()); |
| 153 | + if ( failOnError ) { |
| 154 | + System.exit(1); |
| 155 | + } |
| 156 | + } |
| 157 | + } |
| 158 | + |
| 159 | + /* |
| 160 | + * Optionally loads an upper ontology from command line options. |
| 161 | + */ |
| 162 | + private OWLOntology getUpperOntology(CommandLine line) throws IOException { |
| 163 | + OWLOntology ontology = null; |
| 164 | + if ( line.hasOption("upper-ontology") ) { |
| 165 | + ontology = ioHelper.loadOntology(line.getOptionValue("upper-ontology"), true); |
| 166 | + } else if ( line.hasOption("upper-ontology-iri") ) { |
| 167 | + ontology = ioHelper.loadOntology(getIRI(line.getOptionValue("upper-ontology-iri"), "upper-ontology-iri")); |
| 168 | + } else if ( CommandLineHelper.getBooleanValue(line, "use-cob", false) ) { |
| 169 | + ontology = ioHelper.loadOntology(Constants.COB_IRI); |
| 170 | + } |
| 171 | + |
| 172 | + return ontology; |
| 173 | + } |
| 174 | + |
| 175 | + /* |
| 176 | + * Extracts self-declared roots from the ontology. |
| 177 | + */ |
| 178 | + private Set<OWLClass> getPreferredRoots(OWLOntology ontology) { |
| 179 | + Set<OWLClass> roots = new HashSet<>(); |
| 180 | + for ( OWLAnnotation annot : ontology.getAnnotations() ) { |
| 181 | + if ( annot.getProperty().getIRI().equals(Constants.PREFERRED_ROOT_PROPERTY) ) { |
| 182 | + OWLAnnotationValue value = annot.getValue(); |
| 183 | + if ( value.isIRI() ) { |
| 184 | + roots.add(factory.getOWLClass(value.asIRI().get())); |
| 185 | + } else { |
| 186 | + logger.warn("Ignoring non-IRI IAO:0000700 value: {}", value); |
| 187 | + } |
| 188 | + } |
| 189 | + } |
| 190 | + |
| 191 | + return roots; |
| 192 | + } |
| 193 | + |
| 194 | + /* |
| 195 | + * Gets all top-level unaligned classes in the ontology. |
| 196 | + */ |
| 197 | + private Set<OWLClass> getUnalignedClasses(OWLOntology ontology, OWLReasoner reasoner, Set<OWLClass> upperClasses, |
| 198 | + boolean ignoreDangling) { |
| 199 | + Set<OWLClass> unalignedClasses = new HashSet<>(); |
| 200 | + for ( OWLClass klass : ontology.getClassesInSignature(Imports.INCLUDED) ) { |
| 201 | + if ( !klass.isTopEntity() && !upperClasses.contains(klass) && isInBase(klass.getIRI().toString()) ) { |
| 202 | + if ( ignoreDangling && Util.isDangling(ontology, klass) ) { |
| 203 | + continue; |
| 204 | + } |
| 205 | + if ( Util.isObsolete(ontology, klass) ) { |
| 206 | + continue; |
| 207 | + } |
| 208 | + |
| 209 | + Set<OWLClass> ancestors = reasoner.getSuperClasses(klass, false).getFlattened(); |
| 210 | + boolean aligned = false; |
| 211 | + for ( OWLClass upperClass : upperClasses ) { |
| 212 | + if ( ancestors.contains(upperClass) ) { |
| 213 | + aligned = true; |
| 214 | + break; |
| 215 | + } |
| 216 | + } |
| 217 | + if ( !aligned ) { |
| 218 | + if ( ancestors.size() == 1 ) { |
| 219 | + // This is already a top-level class |
| 220 | + logger.debug("Unaligned class: {}", klass.getIRI().toQuotedString()); |
| 221 | + unalignedClasses.add(klass); |
| 222 | + } else { |
| 223 | + // Find the top-level ancestor(s) |
| 224 | + for ( OWLClass ancestor : ancestors ) { |
| 225 | + if ( reasoner.getSuperClasses(ancestor, false).isTopSingleton() ) { |
| 226 | + logger.debug("Unaligned class: {} (from {})", ancestor.getIRI().toQuotedString(), |
| 227 | + klass.getIRI().toQuotedString()); |
| 228 | + unalignedClasses.add(ancestor); |
| 229 | + } |
| 230 | + } |
| 231 | + } |
| 232 | + } |
| 233 | + } |
| 234 | + } |
| 235 | + |
| 236 | + return unalignedClasses; |
| 237 | + } |
| 238 | + |
| 239 | + private boolean isInBase(String iri) { |
| 240 | + for ( String base : basePrefixes ) { |
| 241 | + if ( iri.startsWith(base) ) { |
| 242 | + return true; |
| 243 | + } |
| 244 | + } |
| 245 | + return basePrefixes.isEmpty(); |
| 246 | + } |
| 247 | +} |
0 commit comments