package edu.stanford.nlp.sempre.tables; import java.io.*; import java.nio.file.Paths; import java.util.*; import edu.stanford.nlp.sempre.*; import edu.stanford.nlp.sempre.FuzzyMatchFn.FuzzyMatchFnMode; import edu.stanford.nlp.sempre.tables.lambdadcs.ExecutorCache; import edu.stanford.nlp.sempre.tables.lambdadcs.InfiniteUnaryDenotation; import edu.stanford.nlp.sempre.tables.lambdadcs.LambdaDCSException; import edu.stanford.nlp.sempre.tables.lambdadcs.LambdaDCSException.Type; import edu.stanford.nlp.sempre.tables.match.FuzzyMatcher; import edu.stanford.nlp.sempre.tables.serialize.TableReader; import edu.stanford.nlp.sempre.tables.serialize.TableWriter; import fig.basic.*; /** * A knowledge graph constructed from a table. * * - Each row becomes an entity * - Each cell becomes an entity * - Each column becomes a relation between a row and a cell * e.g., (fb:row.r5 fb:row.row.nationality fb:cell.canada) * - Each cell has relations pointing to different normalization nodes * e.g., (fb:cell.3_km fb:cell.cell.number (number 3)) * * @author ppasupat */ public class TableKnowledgeGraph extends KnowledgeGraph implements FuzzyMatchable { public static class Options { @Option(gloss = "Verbosity") public int verbose = 0; @Option(gloss = "Base directory for CSV files") public String baseCSVDir = null; @Option(gloss = "Whether to cache TableKnowledgeGraph") public boolean cacheTableKnowledgeGraphs = true; @Option(gloss = "Forbid row.row.next on multiple rows") public boolean forbidNextOnManyRows = true; @Option(gloss = "Set up executor cache for each graph (must manually clear, or else will get memory overflow)") public boolean individualExecutorCache = false; } public static Options opts = new Options(); // ============================================================ // Fields // ============================================================ public List<TableRow> rows; public List<TableColumn> columns; public Set<TableCellProperties> cellProperties; public Set<NameValue> cellParts; public final String filename; // "fb:row.r5" --> TableRow object Map<String, TableRow> rowIdToTableRow; // "fb:row.row.population" --> TableColumn object Map<String, TableColumn> relationIdToTableColumn; // "fb:cell.palo_alto_ca" --> TableCellProperties object Map<String, TableCellProperties> cellIdToTableCellProperties; FuzzyMatcher fuzzyMatcher; public ExecutorCache executorCache; @Override public void clean() { if (executorCache != null) executorCache.clearCache(this); } // ============================================================ // Constructor // ============================================================ /** * Construct a new TableKnowledgeGraph from a String matrix. * Does not cache the data. */ public TableKnowledgeGraph(String filename, Iterable<String[]> data) { this.filename = filename; // Used column names (no two columns have the same id) Set<String> usedColumnNames = new HashSet<>(); // Cells with the same string content gets the same id. Map<String, String> originalStringToCellId = new HashMap<>(); // Parts with the same string content gets the same id. Map<String, String> originalStringToPartId = new HashMap<>(); // Go though the data for (String[] record : data) { if (columns == null) { // Initialize rows = new ArrayList<>(); columns = new ArrayList<>(); rowIdToTableRow = new HashMap<>(); relationIdToTableColumn = new HashMap<>(); cellIdToTableCellProperties = new HashMap<>(); // Read the header row for (String entry : record) { String normalizedEntry = StringNormalizationUtils.characterNormalize(entry).toLowerCase(); String canonicalName = TableTypeSystem.canonicalizeName(normalizedEntry); String columnName = TableTypeSystem.getUnusedName(canonicalName, usedColumnNames); TableColumn column = new TableColumn(entry, columnName, columns.size()); columns.add(column); usedColumnNames.add(columnName); relationIdToTableColumn.put(column.relationNameValue.id, column); } } else { // Read the content row if (record.length != columns.size()) { LogInfo.warnings("Table has %d columns but row has %d cells: %s | %s", columns.size(), record.length, columns, Fmt.D(record)); } TableRow currentRow = new TableRow(rows.size()); rowIdToTableRow.put(currentRow.nameValue.id, currentRow); rows.add(currentRow); for (int i = 0; i < columns.size(); i++) { TableColumn column = columns.get(i); String cellString = (i < record.length) ? record[i] : ""; // Create a NameValue String id = TableTypeSystem.getOrCreateName(cellString, originalStringToCellId, (String canonicalName) -> TableTypeSystem.getCellName(canonicalName)); TableCellProperties properties = cellIdToTableCellProperties.get(id); if (properties == null) cellIdToTableCellProperties.put(id, properties = new TableCellProperties(id, cellString)); TableCell.createAndAddTo(properties, column, currentRow); properties.columns.add(column); } } } // Generate cell properties by analyzing cell content in each column for (TableColumn column : columns) StringNormalizationUtils.analyzeColumn(column, originalStringToPartId); // Collect cell properties for public access cellProperties = new HashSet<>(cellIdToTableCellProperties.values()); cellParts = new HashSet<>(); for (TableCellProperties properties : cellProperties) for (Value part : properties.metadata.get(TableTypeSystem.CELL_PART_VALUE)) cellParts.add((NameValue) part); // Precompute normalized strings for fuzzy matching fuzzyMatcher = FuzzyMatcher.getFuzzyMatcher(this); executorCache = opts.individualExecutorCache ? new ExecutorCache() : null; } /** * Read CSV or TSV file. */ TableKnowledgeGraph(String filename) throws IOException { this(filename, new TableReader(filename)); } // Cache (don't create multiple graphs for the same CSV or TSV file) static final Map<String, TableKnowledgeGraph> filenameToGraph = new HashMap<>(); public static synchronized TableKnowledgeGraph fromRootedFilename(String filename) { // Get from cache if possible TableKnowledgeGraph graph = filenameToGraph.get(filename); if (graph == null) { if (opts.verbose >= 1) LogInfo.logs("create new TableKnowledgeGraph from filename = %s", filename); StopWatchSet.begin("TableKnowledgeGraph.new"); try { graph = new TableKnowledgeGraph(filename); } catch (IOException e) { throw new RuntimeException(e); } StopWatchSet.end(); if (opts.cacheTableKnowledgeGraphs) filenameToGraph.put(filename, graph); } return graph; } public static TableKnowledgeGraph fromFilename(String filename) { return fromRootedFilename(new File(opts.baseCSVDir, filename).getPath()); } public static TableKnowledgeGraph fromLispTree(LispTree tree) { if (tree.children.size() > 3 && "rooted-path".equals(tree.child(3).value)) return fromRootedFilename(tree.child(2).value); else return fromFilename(tree.child(2).value); } // ============================================================ // Construct from existing cells // ============================================================ /** * Construct a new TableKnowledgeGraph using the same columns as an old table * but with different cell ordering. * Does not cache the data. */ public TableKnowledgeGraph(String filename, List<TableColumn> oldColumns, List<List<TableCellProperties>> oldCells, boolean cellsAreGroupedByColumn) { this.filename = filename; rows = new ArrayList<>(); columns = new ArrayList<>(); rowIdToTableRow = new HashMap<>(); relationIdToTableColumn = new HashMap<>(); cellIdToTableCellProperties = new HashMap<>(); // Header row for (TableColumn oldColumn : oldColumns) { TableColumn column = new TableColumn(oldColumn); columns.add(column); relationIdToTableColumn.put(column.relationNameValue.id, column); } // Sanity check int numRows, numColumns = columns.size(); if (cellsAreGroupedByColumn) { // oldCells[column][row] if (oldCells.size() != numColumns) throw new RuntimeException("Mismatched sizes: oldCells has " + oldCells.size() + " != " + numColumns + " columns"); numRows = oldCells.get(0).size(); for (List<TableCellProperties> oldCellsRow : oldCells) if (oldCellsRow.size() != numRows) throw new RuntimeException("Mismatched sizes: oldCells has " + oldCells.size() + " != " + numColumns + " rows"); } else { // oldCells[row][column] numRows = oldCells.size(); for (List<TableCellProperties> oldCellsColumn : oldCells) if (oldCellsColumn.size() != numColumns) throw new RuntimeException("Mismatched sizes: oldCells has " + oldCells.size() + " != " + numColumns + " columns"); } // Content rows for (int i = 0; i < numRows; i++) { TableRow currentRow = new TableRow(i); rows.add(currentRow); rowIdToTableRow.put(currentRow.nameValue.id, currentRow); for (int j = 0; j < numColumns; j++) { TableColumn column = columns.get(j); TableCellProperties properties = new TableCellProperties( cellsAreGroupedByColumn ? oldCells.get(j).get(i) : oldCells.get(i).get(j)); cellIdToTableCellProperties.put(properties.id, properties); TableCell.createAndAddTo(properties, column, currentRow); } } // Finalize cellProperties = new HashSet<>(cellIdToTableCellProperties.values()); cellParts = new HashSet<>(); for (TableCellProperties properties : cellProperties) for (Value part : properties.metadata.get(TableTypeSystem.CELL_PART_VALUE)) cellParts.add((NameValue) part); // Precompute normalized strings for fuzzy matching fuzzyMatcher = FuzzyMatcher.getFuzzyMatcher(this); executorCache = opts.individualExecutorCache ? new ExecutorCache() : null; } // ============================================================ // Convert to other formats // ============================================================ @Override public LispTree toLispTree() { if (filename != null) { // short version: just print the filename LispTree tree = LispTree.proto.newList(); tree.addChild("graph"); tree.addChild("tables.TableKnowledgeGraph"); if (filename.startsWith(opts.baseCSVDir)) tree.addChild(Paths.get(opts.baseCSVDir).relativize(Paths.get(filename)).toString()); else { tree.addChild(filename); tree.addChild("rooted-path"); } return tree; } return toTableValue().toLispTree(); } @Override public LispTree toShortLispTree() { return toLispTree(); } public TableValue toTableValue() { List<String> tableValueHeader = new ArrayList<>(); List<List<Value>> tableValueRows = new ArrayList<>(); for (TableColumn column : columns) { tableValueHeader.add(column.originalString); } for (TableRow row : rows) { List<Value> tableValueRow = new ArrayList<>(); for (TableCell cell : row.children) { tableValueRow.add(cell.properties.nameValue); } tableValueRows.add(tableValueRow); } return new TableValue(tableValueHeader, tableValueRows); } public void log() { new TableWriter(this).log(); } // ============================================================ // Fuzzy matching // ============================================================ @Override public Collection<Formula> getFuzzyMatchedFormulas(String term, FuzzyMatchFn.FuzzyMatchFnMode mode) { return fuzzyMatcher.getFuzzyMatchedFormulas(term, mode); } @Override public Collection<Formula> getFuzzyMatchedFormulas( List<String> sentence, int startIndex, int endIndex, FuzzyMatchFnMode mode) { return fuzzyMatcher.getFuzzyMatchedFormulas(sentence, startIndex, endIndex, mode); } @Override public Collection<Formula> getAllFormulas(FuzzyMatchFn.FuzzyMatchFnMode mode) { return fuzzyMatcher.getAllFormulas(mode); } // ============================================================ // Query // ============================================================ public static final NameValue TYPE = new NameValue(CanonicalNames.TYPE); public static final NameValue ROW_TYPE = new NameValue(TableTypeSystem.ROW_TYPE); public static final NameValue CELL_TYPE = new NameValue(TableTypeSystem.CELL_TYPE); public static final NameValue PART_TYPE = new NameValue(TableTypeSystem.PART_TYPE); /** Return all y such that x in firsts and (x,r,y) in graph */ @Override public List<Value> joinFirst(Value r, Collection<Value> firsts) { return joinSecond(CanonicalNames.reverseProperty(r), firsts); } /** Return all x such that y in seconds and (x,r,y) in graph */ @Override public List<Value> joinSecond(Value r, Collection<Value> seconds) { List<Value> answer = new ArrayList<>(); for (Pair<Value, Value> pair : filterSecond(r, seconds)) answer.add(pair.getFirst()); return answer; } /** Return all (x,y) such that x in firsts and (x,r,y) in graph */ @Override public List<Pair<Value, Value>> filterFirst(Value r, Collection<Value> firsts) { return getReversedPairs(filterSecond(CanonicalNames.reverseProperty(r), firsts)); } /* * - {one,many} to one: Each X maps to 1 Y: * X-Y = row-row, row-primitive, primitive-row, row-cell, cell-primitive * - {one,many} to many: Remove duplicates first, then each X maps to possibly many Y's * X-Y = cell-row, primitive-cell */ /** Return all (x,y) such that y in seconds and (x,r,y) in graph */ @Override public List<Pair<Value, Value>> filterSecond(Value r, Collection<Value> seconds) { List<Pair<Value, Value>> answer = new ArrayList<>(); if (CanonicalNames.isReverseProperty(r)) { r = CanonicalNames.reverseProperty(r); if (r.equals(TYPE)) { //////////////////////////////////////////////////////////// // (!fb:type.object.type fb:row.r5) --> fb:type.row // Not handled right now. throw new BadFormulaException("Unhandled! " + r); } else if (r.equals(TableTypeSystem.ROW_NEXT_VALUE)) { //////////////////////////////////////////////////////////// // (!fb:row.row.next fb:row.r5) --> fb:row.r6 if (opts.forbidNextOnManyRows && seconds.size() != 1 && seconds != InfiniteUnaryDenotation.STAR_UNARY) { throw new LambdaDCSException(Type.nonSingletonList, "cannot call next on " + seconds.size() + " rows."); } if (seconds.size() == Integer.MAX_VALUE) { for (int i = 0; i < rows.size() - 1; i++) { if (!seconds.contains(rows.get(i).nameValue)) continue; answer.add(new Pair<>(rows.get(i + 1).nameValue, rows.get(i).nameValue)); } } else { for (Value value : seconds) { if (!(value instanceof NameValue)) continue; TableRow row = rowIdToTableRow.get(((NameValue) value).id); if (row == null) continue; int i = row.index; if (i + 1 >= rows.size()) continue; answer.add(new Pair<>(rows.get(i + 1).nameValue, row.nameValue)); } } } else if (r.equals(TableTypeSystem.ROW_INDEX_VALUE)) { //////////////////////////////////////////////////////////// // (!fb:row.row.index fb:row.r5) --> (number 5) if (seconds.size() == Integer.MAX_VALUE) { for (TableRow row : rows) { if (!seconds.contains(row.nameValue)) continue; answer.add(new Pair<>(row.indexValue, row.nameValue)); } } else { for (Value value : seconds) { if (!(value instanceof NameValue)) continue; TableRow row = rowIdToTableRow.get(((NameValue) value).id); if (row == null) continue; answer.add(new Pair<>(row.indexValue, row.nameValue)); } } } else if (TableTypeSystem.isCellProperty(r)) { //////////////////////////////////////////////////////////// // (!fb:cell.cell.number fb:cell.5_dollars) --> (number 5) if (seconds.size() == Integer.MAX_VALUE) { for (TableColumn column : columns) { for (TableCell cell : column.children) { for (Value property : cell.properties.metadata.get(r)) { if (!seconds.contains(cell.properties.nameValue)) continue; answer.add(new Pair<>(property, cell.properties.nameValue)); } } } } else { for (Value value : seconds) { if (!(value instanceof NameValue)) continue; TableCellProperties properties = cellIdToTableCellProperties.get(((NameValue) value).id); if (properties == null) continue; for (Value property : properties.metadata.get(r)) { answer.add(new Pair<>(property, properties.nameValue)); } } } } else if (TableTypeSystem.isRowProperty(r)) { //////////////////////////////////////////////////////////// // (!fb:row.row.nationality fb:row.r5) --> fb:cell.canada if (seconds.size() == Integer.MAX_VALUE) { for (int i = 0; i < columns.size(); i++) { if (!r.equals(columns.get(i).relationNameValue)) continue; for (TableRow row : rows) { if (!seconds.contains(row.nameValue)) continue; answer.add(new Pair<>(row.children.get(i).properties.nameValue, row.nameValue)); } } } else { for (int i = 0; i < columns.size(); i++) { if (!r.equals(columns.get(i).relationNameValue)) continue; for (Value value : seconds) { if (!(value instanceof NameValue)) continue; TableRow row = rowIdToTableRow.get(((NameValue) value).id); if (row == null) continue; answer.add(new Pair<>(row.children.get(i).properties.nameValue, row.nameValue)); } } } } else if (TableTypeSystem.isRowConsecutiveProperty(r)) { //////////////////////////////////////////////////////////// // (!fb:row.consecutive.nationality fb:row.r5) --> (number 2) for (int i = 0; i < columns.size(); i++) { if (!r.equals(columns.get(i).relationConsecutiveNameValue)) continue; int count = 0; NameValue lastCell = null; for (TableRow row : rows) { if (row.children.get(i).properties.nameValue.equals(lastCell)) count++; else { count = 1; lastCell = row.children.get(i).properties.nameValue; } if (!seconds.contains(row.nameValue)) continue; answer.add(new Pair<>(new NumberValue(count), row.nameValue)); } } } } else { if (r.equals(TYPE)) { //////////////////////////////////////////////////////////// // (fb:type.object.type fb:type.row) --> {fb:row.r1, fb:row.r2, ...} for (Value second : seconds) { if (second.equals(ROW_TYPE)) { for (TableRow row : rows) answer.add(new Pair<>(row.nameValue, second)); } else if (second.equals(CELL_TYPE)) { for (TableCellProperties properties : cellProperties) answer.add(new Pair<>(properties.nameValue, second)); } else if (second.equals(PART_TYPE)) { for (NameValue part : cellParts) answer.add(new Pair<>(part, second)); } } } else if (r.equals(TableTypeSystem.ROW_NEXT_VALUE)) { //////////////////////////////////////////////////////////// // (fb:row.row.next fb:row.r5) --> fb:row.r4 if (opts.forbidNextOnManyRows && seconds.size() != 1 && seconds != InfiniteUnaryDenotation.STAR_UNARY) { throw new LambdaDCSException(Type.nonSingletonList, "cannot call next on " + seconds.size() + " rows."); } if (seconds.size() == Integer.MAX_VALUE) { for (int i = 1; i < rows.size(); i++) { if (!seconds.contains(rows.get(i).nameValue)) continue; answer.add(new Pair<>(rows.get(i - 1).nameValue, rows.get(i).nameValue)); } } else { for (Value value : seconds) { if (!(value instanceof NameValue)) continue; TableRow row = rowIdToTableRow.get(((NameValue) value).id); if (row == null) continue; int i = row.index; if (i - 1 < 0) continue; answer.add(new Pair<>(rows.get(i - 1).nameValue, row.nameValue)); } } } else if (r.equals(TableTypeSystem.ROW_INDEX_VALUE)) { //////////////////////////////////////////////////////////// // (fb:row.row.index (number 5)) --> fb:row.r5 if (seconds.size() == Integer.MAX_VALUE) { for (TableRow row : rows) { if (!seconds.contains(row.indexValue)) continue; answer.add(new Pair<>(row.nameValue, row.indexValue)); } } else { for (Value value : seconds) { if (!(value instanceof NumberValue)) continue; double x = ((NumberValue) value).value; if (Math.abs(x - Math.round(x)) > 1e-6) continue; // Ignore non-integers int i = (int) x; if (i < 0 || i >= rows.size()) continue; TableRow row = rows.get(i); answer.add(new Pair<>(row.nameValue, row.indexValue)); } } } else if (TableTypeSystem.isCellProperty(r)) { //////////////////////////////////////////////////////////// // (fb:cell.cell.number (number 5)) --> {fb:cell.5 fb:cell.5_dollars, ...} // Possibly with repeated id (if there are multiple cells with that id) for (TableColumn column : columns) { for (TableCell cell : column.children) { for (Value property : cell.properties.metadata.get(r)) { if (!seconds.contains(property)) continue; answer.add(new Pair<>(cell.properties.nameValue, property)); } } } } else if (TableTypeSystem.isRowProperty(r)) { //////////////////////////////////////////////////////////// // (fb:row.row.nationality fb:cell.canada) --> fb:row.r5 for (int i = 0; i < columns.size(); i++) { if (!r.equals(columns.get(i).relationNameValue)) continue; for (TableRow row : rows) { if (!seconds.contains(row.children.get(i).properties.nameValue)) continue; answer.add(new Pair<>(row.nameValue, row.children.get(i).properties.nameValue)); } } } else if (TableTypeSystem.isRowConsecutiveProperty(r)) { //////////////////////////////////////////////////////////// // (fb:row.consecutive.nationality (number 2)) --> fb:row.r5 for (int i = 0; i < columns.size(); i++) { if (!r.equals(columns.get(i).relationConsecutiveNameValue)) continue; int count = 0; NameValue lastCell = null; for (TableRow row : rows) { if (row.children.get(i).properties.nameValue.equals(lastCell)) count++; else { count = 1; lastCell = row.children.get(i).properties.nameValue; } if (!seconds.contains(new NumberValue(count))) continue; answer.add(new Pair<>(row.nameValue, new NumberValue(count))); } } } } return answer; } // ============================================================ // Methods specific to TableKnowledgeGraph // ============================================================ public void populateStats(Evaluation evaluation) { evaluation.add("rows", rows.size()); evaluation.add("columns", columns.size()); evaluation.add("cells", rows.size() * columns.size()); } public int numRows() { return rows.size(); } public int numColumns() { return columns.size(); } public int numUniqueCells() { return cellProperties.size(); } public TableRow getRow(int rowIndex) { return rows.get(rowIndex); } public TableColumn getColumn(int columnIndex) { return columns.get(columnIndex); } public TableCell getCell(int rowIndex, int colIndex) { return rows.get(rowIndex).children.get(colIndex); } public List<String> getAllColumnStrings() { List<String> columnStrings = new ArrayList<>(); for (TableColumn column : columns) { columnStrings.add(column.originalString); } return columnStrings; } public List<String> getAllCellStrings() { List<String> cellStrings = new ArrayList<>(); for (TableColumn column : columns) { for (TableCell cell : column.children) { cellStrings.add(cell.properties.originalString); } } return cellStrings; } public String getOriginalString(Value value) { return (value instanceof NameValue) ? getOriginalString(((NameValue) value).id) : null; } public String getOriginalString(String nameValueId) { if (nameValueId.startsWith("!")) nameValueId = nameValueId.substring(1); if (cellIdToTableCellProperties.containsKey(nameValueId)) return cellIdToTableCellProperties.get(nameValueId).originalString; if (relationIdToTableColumn.containsKey(nameValueId)) return relationIdToTableColumn.get(nameValueId).originalString; return null; } public Value getNameValueWithOriginalString(NameValue value) { if (value.description == null) value = new NameValue(value.id, getOriginalString(value.id)); return value; } public ListValue getListValueWithOriginalStrings(ListValue answers) { List<Value> values = new ArrayList<>(); for (Value value : answers.values) { if (value instanceof NameValue) { NameValue name = (NameValue) value; if (name.description == null) value = new NameValue(name.id, getOriginalString(name.id)); } values.add(value); } return new ListValue(values); } /** * Return a list of rows that contain a cell with the specified NameValue ID. */ public List<Integer> getRowsOfCellId(String nameValueId) { List<Integer> answer = new ArrayList<>(); TableCellProperties properties = cellIdToTableCellProperties.get(nameValueId); if (properties == null) return answer; for (TableColumn column : properties.columns) { for (int i = 0; i < column.children.size(); i++) { if (column.children.get(i).properties.id.equals(nameValueId)) answer.add(i); } } return answer; } /** * Return a list of columns that contain a cell with the specified NameValue ID. */ public List<String> getColumnsOfCellId(String nameValueId) { List<String> answer = new ArrayList<>(); TableCellProperties properties = cellIdToTableCellProperties.get(nameValueId); if (properties == null) return answer; for (TableColumn column : properties.columns) { answer.add(column.columnName); } return answer; } // ============================================================ // Test // ============================================================ public static void main(String[] args) { //opts.baseCSVDir = "tables/toy-examples/random/"; //String filename = "nikos_machlas.csv"; StringNormalizationUtils.opts.verbose = 5; //LanguageAnalyzer.opts.languageAnalyzer = "corenlp.CoreNLPAnalyzer"; StringNormalizationUtils.opts.numberCanStartAnywhere = true; StringNormalizationUtils.opts.num2CanStartAnywhere = true; opts.baseCSVDir = "lib/data/tables/"; String filename = "csv/200-csv/0.csv"; TableKnowledgeGraph graph = (TableKnowledgeGraph) KnowledgeGraph.fromLispTree( LispTree.proto.parseFromString("(graph tables.TableKnowledgeGraph " + filename + ")")); //LogInfo.logs("%s", graph.toLispTree().toStringWrap()); //LogInfo.logs("%s", graph.toTableValue().toLispTree().toStringWrap(100)); for (TableColumn column : graph.columns) { LogInfo.begin_track("%s (%s)", column.columnName, column.originalString); for (TableCell cell : column.children) { LogInfo.logs("%s (%s) %s", cell.properties.nameValue, cell.properties.originalString, cell.properties.metadata); } LogInfo.end_track(); } } }