Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions opennlp-api/src/main/java/opennlp/tools/termvector/TermVector.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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.
*/

package opennlp.tools.termvector;

import java.util.List;

import opennlp.tools.commons.ThreadSafe;
import opennlp.tools.util.Span;

/**
* One entry of a term vector layer: a term, how often it occurs in the document, and
* where.
*
* <p>The {@link #term()} is the term's identity as the producing annotator determined
* it, typically a normalized form; the {@link #spans()} are the occurrence offsets and
* always point into the document's <em>original</em> text, never into a normalized form,
* so an index consumer can highlight every occurrence in what the caller supplied.</p>
*
* <p>A term vector comes in one of two shapes, told apart by whether {@link #spans()} is
* empty. A <em>full</em> vector carries one span per occurrence, so
* {@code spans().size() == frequency()}. A <em>scoring-only</em> vector carries no spans
* at all, so consumers that only need term frequencies do not pay for offset storage.
* There is no third shape: a non-empty span list must match the frequency exactly.</p>
*
* <p>Instances are immutable: the span list is copied on construction and the copy is
* unmodifiable.</p>
*
* @param term The term string. Must not be {@code null}.
* @param frequency The number of occurrences in the document. Must be at least one.
* @param spans The occurrence spans in original text coordinates, one per occurrence,
* or an empty list for a scoring-only vector. Must not be {@code null} or
* contain {@code null} and, when non-empty, must hold exactly
* {@code frequency} spans.
*
* @since 3.0.0
*/
@ThreadSafe
public record TermVector(String term, int frequency, List<Span> spans) {

/**
* Validates the term vector and detaches the span list from the caller's input.
*
* @throws IllegalArgumentException Thrown if {@code term} is {@code null},
* {@code frequency} is below one, {@code spans} is or contains {@code null},
* or a non-empty {@code spans} list does not hold exactly {@code frequency}
* spans.
*/
public TermVector {
if (term == null) {
throw new IllegalArgumentException("term must not be null");
}
if (frequency < 1) {
throw new IllegalArgumentException("frequency must be at least one: " + frequency);
}
if (spans == null) {
throw new IllegalArgumentException("spans must not be null");
}
for (final Span span : spans) {
if (span == null) {
throw new IllegalArgumentException("spans must not contain null");
}
}
if (!spans.isEmpty() && spans.size() != frequency) {
throw new IllegalArgumentException("a full term vector holds one span per "
+ "occurrence: frequency is " + frequency + " but spans holds " + spans.size());
}
spans = List.copyOf(spans);
}

/**
* Creates a full {@link TermVector} whose frequency is derived from the occurrence
* spans.
*
* @param term The term string. Must not be {@code null}.
* @param spans The occurrence spans in original text coordinates. Must not be
* {@code null} or empty.
* @return A {@link TermVector} with {@code frequency() == spans.size()}. Never
* {@code null}.
* @throws IllegalArgumentException Thrown if {@code term} is {@code null} or
* {@code spans} is {@code null} or empty.
*/
public static TermVector withSpans(String term, List<Span> spans) {
if (spans == null || spans.isEmpty()) {
throw new IllegalArgumentException("spans must not be null or empty");
}
return new TermVector(term, spans.size(), spans);
}

/**
* Creates a scoring-only {@link TermVector} that carries the occurrence count without
* any offsets.
*
* @param term The term string. Must not be {@code null}.
* @param frequency The number of occurrences in the document. Must be at least one.
* @return A {@link TermVector} whose {@link #spans()} is empty. Never {@code null}.
* @throws IllegalArgumentException Thrown if {@code term} is {@code null} or
* {@code frequency} is below one.
*/
public static TermVector count(String term, int frequency) {
return new TermVector(term, frequency, List.of());
}
}
105 changes: 105 additions & 0 deletions opennlp-api/src/test/java/opennlp/tools/termvector/TermVectorTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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.
*/

package opennlp.tools.termvector;

import java.util.ArrayList;
import java.util.List;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import opennlp.tools.util.Span;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
* Verifies the {@link TermVector} invariants: the two legal shapes (full with one span
* per occurrence, scoring-only with none), the validation of everything in between, and
* the immutability of the span list.
*/
public class TermVectorTest {

@Test
void testWithSpansDerivesTheFrequency() {
final TermVector vector =
TermVector.withSpans("dog", List.of(new Span(4, 7), new Span(19, 22)));
assertEquals("dog", vector.term());
assertEquals(2, vector.frequency());
assertEquals(List.of(new Span(4, 7), new Span(19, 22)), vector.spans());
}

@Test
void testCountCarriesNoSpans() {
final TermVector vector = TermVector.count("dog", 3);
assertEquals("dog", vector.term());
assertEquals(3, vector.frequency());
assertTrue(vector.spans().isEmpty());
}

@Test
void testSpanListIsDetachedFromTheCallersInput() {
final List<Span> spans = new ArrayList<>(List.of(new Span(0, 3)));
final TermVector vector = TermVector.withSpans("the", spans);
spans.add(new Span(4, 7));
assertEquals(1, vector.spans().size());
assertThrows(UnsupportedOperationException.class,
() -> vector.spans().add(new Span(8, 11)));
}

@Test
void testNullTermIsRejected() {
assertThrows(IllegalArgumentException.class,
() -> new TermVector(null, 1, List.of(new Span(0, 1))));
}

@Test
void testNullSpanListIsRejected() {
assertThrows(IllegalArgumentException.class, () -> new TermVector("dog", 1, null));
}

@Test
void testNullSpanElementIsRejected() {
final List<Span> spans = new ArrayList<>();
spans.add(new Span(0, 3));
spans.add(null);
assertThrows(IllegalArgumentException.class, () -> new TermVector("dog", 2, spans));
assertThrows(IllegalArgumentException.class, () -> TermVector.withSpans("dog", spans));
}

@ParameterizedTest
@ValueSource(ints = {0, -1, Integer.MIN_VALUE})
void testFrequencyBelowOneIsRejected(int frequency) {
assertThrows(IllegalArgumentException.class, () -> TermVector.count("dog", frequency));
}

@Test
void testEmptySpanListCannotDeriveAFrequency() {
assertThrows(IllegalArgumentException.class,
() -> TermVector.withSpans("dog", List.of()));
}

@Test
void testPartialSpanListIsRejected() {
// Two occurrences but only one recorded span: neither full nor scoring-only.
assertThrows(IllegalArgumentException.class,
() -> new TermVector("dog", 2, List.of(new Span(0, 3))));
}
}
Loading
Loading